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`)。
+162
View File
@@ -0,0 +1,162 @@
# 附件管理(上传 / 查询 / 删除 / 存储扩展)
统一的上传-附件闭环:上传接口负责收文件,`FileStorageService` 自动写入附件表,提供分页查询与删除接口;存储层通过**事件钩子**可扩展 OSS / FTP 等自定义存储,业务代码无需改动。
## 接口一览(均需登录)
| 接口 | 方法 | 说明 |
|------|------|------|
| `/api/v1/common/upload` | POST | 上传文件(multipart,字段 `file`),成功自动落库 |
| `/api/v1/attachment/list` | GET | 我的附件分页(可 `module` 过滤) |
| `/api/v1/attachment/delete` | POST | 删除附件(仅本人),物理删文件 + 软删记录 |
### 1. 上传
```
POST /api/v1/common/upload
Content-Type: multipart/form-data
Authorization: Bearer <token>
```
| 参数 | 说明 |
|------|------|
| `file` | 上传的文件(必填) |
默认校验:大小 ≤ 10MB,后缀白名单 `jpg,jpeg,png,gif,webp,bmp,mp4,mov,mp3,wav,zip,rar,doc,docx,xls,xlsx,pdf,txt`。
成功响应:
```json
{
"code": 0,
"msg": "上传成功",
"data": {
"url": "https://demo.ywxapp.cn/storage/2026/08/26/xxxx.jpg",
"path": "2026/08/26/xxxx.jpg",
"name": "xxxx.jpg",
"size": 102400
}
}
```
### 2. 查询(我的附件分页)
```
GET /api/v1/attachment/list?page=1&size=20&module=api
```
| 参数 | 默认 | 说明 |
|------|------|------|
| `page` | 1 | 页码 |
| `size` | 20 | 每页条数(上限 100) |
| `module` | - | 模块过滤(可选,如 `api` / `forum` / `chat`) |
响应:`data.list`(附件数组,含 `id/name/url/path/size/ext/mime/is_image/module/create_at` 等)、`data.total`(总数)。
### 3. 删除
```
POST /api/v1/attachment/delete
body: { "id": 1 }
```
- 仅能删除**自己**的附件(`uid` 归属校验)
- 流程:先物理删除文件(失败不阻断,`data.deleted_file=false` 提示)→ 软删记录(`delete_at`)
- 成功响应:`{ "code": 0, "msg": "删除成功", "data": { "deleted_file": true } }`
## 附件表 `common_attachment`
| 字段 | 说明 |
|------|------|
| `uid` | 上传者(会员 uid,未登录 0) |
| `module` | 模块标识(`api` / `forum` / `chat` …) |
| `name` / `original` | 存储文件名 / 原始文件名 |
| `path` / `url` | 相对路径 / 可访问 URL |
| `size` / `ext` / `mime` | 大小 / 后缀 / MIME |
| `storage` / `driver_info` | 存储驱动标识 / 驱动附加信息(JSON) |
| `is_image` / `width` / `height` | 图片标记与尺寸 |
| `delete_at` | 软删除时间(0=未删,模型启用 SoftDelete) |
表结构自愈:模型 `ensureSchema()` 会自动补 `delete_at` 列(旧库无需手工 ALTER)。
## 事件钩子(存储扩展)
所有上传/删除统一走 `FileStorageService`,提供 4 个事件:
| 事件 | 阶段 | 返回约定 |
|------|------|----------|
| `FileUpload` | 上传前(分发) | 监听并**返回结果数组**即接管上传(OSS/FTP) |
| `FileUploaded` | 上传成功后(通知) | 无需返回,用于缩略图/水印/审计 |
| `FileDelete` | 删除文件前(分发) | 监听并**返回 true** 即接管物理删除 |
| `FileDeleted` | 删除成功后(通知) | 无需返回 |
> 中断式事件(`Event::trigger(..., true)`):必须返回**非空数组 / true** 才视为接管;空数组会被当作未接管,回落内置驱动。
### 接管上传(OSS / FTP 示例)
```php
use think\facade\Event;
Event::listen('FileUpload', function (array $payload) {
$file = $payload['file']; // \think\file\UploadedFile
$path = $payload['path']; // 目标路径
// --- 调用你的 OSS / FTP SDK 上传,拿到远端地址 ---
$remotePath = 'uploads/' . $path;
$oss->put($remotePath, file_get_contents($file->getRealPath()));
// 返回数组即接管成功(键 name/path/url/storage 写入附件表)
return [
'name' => basename($remotePath),
'path' => $remotePath,
'url' => 'https://cdn.example.com/' . $remotePath,
'storage' => 'alioss',
];
});
```
### 上传成功后扩展(缩略图 / 水印)
```php
Event::listen('FileUploaded', function (array $data) {
// $data: {id,module,original,name,path,url,size,ext,mime,storage,is_image,width,height,...}
if (!empty($data['is_image'])) {
// 生成缩略图、加水印、CDN 刷新……
}
});
```
### 接管物理删除
```php
Event::listen('FileDelete', function (array $payload) {
// 删除 OSS / FTP 上的 $payload['path']
$oss->delete($payload['path']);
return true; // 必须返回 true 表示已接管
});
```
## 后台统一管理
系统管理 → **附件管理**(`app/backend/controller/Attachment.php`,权限 `attachment:index|delete`,首次访问自动补录):
- 全站附件一览(不分模块 / 不分上传者),可按文件名、路径、模块检索,可只看图片;
- 顶部概览:附件总数、占用空间、图片数;
- 删除走 `FileStorageService::delete()` → 触发 `FileDelete` 事件(OSS/FTP 在此接管)→ 再软删记录,
所以后台删附件和 API 端删附件走同一条链路,不会出现「记录没了文件还在」。
### 孤儿文件清理
「孤儿」= 磁盘上有、附件表里没有记录的文件(落库失败、记录被硬删、手工拷贝都会留下)。
```bash
php think attachment:clean # 只统计,列出前 20 个(安全)
php think attachment:clean --delete # 真正删除
php think attachment:clean --days=30 # 只处理 30 天前的文件
```
删除同样走 `FileStorageService::delete()`,自定义存储由 `FileDelete` 事件接管。
## 内置存储驱动
默认走 `config/filesystem.php` 的 `storage` 驱动(本地磁盘,落盘 `public/storage/`)。新增驱动:实现 `ywxapp\utils\storage\StorageInterface`(`upload()` 返回 `[name, path, url]`、`delete()` 返回 bool),注册到 `config/filesystem.php` 的 `storages` 列表后即可通过配置切换。
+57
View File
@@ -0,0 +1,57 @@
# 数据表管理(可视化建表)
系统管理 → **数据表管理**(`/admin/datatable/index`)。和「数据库备份」是两件事:
那边管数据(备份 / 还原),这边管结构(建表 / 看结构 / 优化 / 删表)。
## 能做什么
| 功能 | 入口 | 说明 |
|------|------|------|
| 表列表 | 页面表格 | 表名、备注、引擎、行数、大小、字符集;支持关键字搜索与分页 |
| 新建表 | 顶部「新建表」 | 可视化字段设计 → **生成 SQL 预览** → 确认后执行 |
| 查看结构 | 行内「结构」 | 字段(类型 / 可空 / 默认值 / 键 / 注释)+ 完整建表语句,可复制 |
| 优化表 | 行内「优化」/ 顶部「优化选中」 | `OPTIMIZE TABLE` |
| 删除表 | 行内「删除」 | 需手动输入表全名二次确认;核心表直接禁止 |
建表成功后页面会给出下一步命令:
```bash
php think addon:crud <插件> -t <表名,不含前缀>
```
## 字段表单说明
每行一个字段:字段名、类型、长度 / 精度、默认值、注释,以及
无符号 / 可空 / 自增 / 主键 / 索引(无 / 普通 / 唯一)五个开关。
- 类型走白名单(21 种常用类型),整型不输出显示宽度(MySQL 8 已废弃);
- `varchar` 默认长度 255、`decimal` 默认 `10,2`;
- 默认值 `NULL`、`CURRENT_TIMESTAMP`、数字字面量不加引号,其余按字符串处理;
- 自增字段必须同时是主键,且只能有一个(MySQL 硬性约束,表单提交前就拦下)。
## 安全约束
DDL 不可逆,这里比普通 CRUD 保守得多:
1. **白名单拼装**:表名、字段名、类型、长度、引擎、字符集、排序规则全部白名单校验,
用户输入的字符串一个字符都不会进入 SQL 语法结构;注释与默认值只做转义。
2. **只新建,不覆盖**:表已存在直接中止,绝不做 `DROP + CREATE`。
3. **删表双重闸**:必须回传与表名完全一致的确认串;`backend_admin` /
`backend_power` / `backend_role` / `backend_group` / `backend_log` /
`backend_menu` 列入保护名单,拒绝删除。
4. **全程留痕**:建表(含失败)、删表、优化都写 `backend_log`,
在「操作日志」里按 `datatable/create`、`datatable/drop` 可查。
## 权限节点
`datatable:index | create | structure | optimize | drop`,与菜单一同在首次访问时
由 `MenuRegistry`(`app/backend/library/MenuRegistry.php`)自动补录,无需手工加节点。
## 相关文件
| 文件 | 作用 |
|------|------|
| `app/backend/controller/Datatable.php` | 控制器:列表 / 结构 / 预览 / 建表 / 删表 / 优化 |
| `app/backend/view/datatable/index.html` | 列表页 + 建表弹窗 + 结构弹窗模板 |
| `public/static/backend/modules/datatable.js` | 表格渲染、字段行增删、SQL 预览与执行 |
| `app/backend/route/app.php` | `Route::group('datatable', ...)` |
+274
View File
@@ -0,0 +1,274 @@
# 设备注册(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 存的历史不会自动迁移。
+519
View File
@@ -0,0 +1,519 @@
# haonav 网址导航 — 开发文档
> 面向开发者。站长使用说明见 [haonav_user_guide.md](haonav_user_guide.md)。
- 命名空间:`addon\haonav\`
- 版本:1.0.9(`addon/haonav/info.php`)
- 路由前缀:`/haonav`(由 `AppService::loadAddonRoutes()` 在 boot 阶段统一包裹)
---
## 1. 目录结构
```
addon/haonav/
├── Addon.php 插件生命周期钩子
├── info.php 插件元信息(版本/入口/事件)
├── common.php 插件公共函数
├── route/app.php 路由表(相对规则,自动加 /haonav 前缀)
├── command/
│ └── CheckLinks.php 死链检测命令行
├── controller/
│ ├── Index.php 前台骨架控制器(12 个 trait 组合)
│ ├── Account.php 会员登录/注册/资料
│ ├── Favorite.php 云端收藏 + 分享
│ ├── Task.php 计划任务入口(checklinks / autocheck)
│ ├── Widget.php 小组件代理(热搜/天气)
│ ├── trait/ ★ 前台控制器按职责拆分(12 个)
│ │ ├── BaseTrait.php 配置/SEO/用户/广告/点击统计/静态资源输出
│ │ ├── HomeTrait.php index + rank
│ │ ├── SearchTrait.php search + suggest + rate
│ │ ├── SiteDetailTrait.php site 详情(JSON-LD)
│ │ ├── CategoryTrait.php category
│ │ ├── ApplyTrait.php submit + apply
│ │ ├── SitemapTrait.php sitemap 双格式
│ │ ├── ReadMetaTrait.php read
│ │ ├── FaviconTrait.php favicon 代理输出
│ │ ├── SnapshotTrait.php 网站快照
│ │ ├── SwTrait.php PWA Service Worker 输出
│ │ └── AdTrait.php adClick 广告点击跳转
│ └── backend/ 后台控制器
│ ├── HaonavBackend.php 后台基类
│ ├── Dashboard.php 数据概览 + 拼音回填
│ ├── Links.php 链接管理 + 回收站
│ ├── Category.php 分类管理
│ ├── Configs.php 配置管理
│ ├── Ad.php 广告位
│ └── Apply.php 申请审核
├── model/
│ ├── Links.php / Category.php / Configure.php / Ad.php / Apply.php
│ ├── Favorite.php / FavoriteShare.php
│ └── ...
├── library/Pinyin.php 拼音转换
├── validate/ 验证器(Category / Link)
└── view/frontend/ 前台视图
├── index/ 7 个页面
│ ├── index.html / search.html / rank.html / detail.html
│ ├── category.html / apply.html
├── favorite/shared.html 收藏分享页
└── public/ 公共 include
├── styles.html ★ CSS 入口壳(见 §5 陷阱)
└── navbar.html 顶部导航
public/static/haonav/
├── css/
│ ├── nav-base.css 全局重置/变量/卡片/响应式(全页面)
│ ├── nav-theme.css 主题面板 + 背景遮罩层
│ ├── nav-festival.css 节日粒子/徽章
│ └── nav-page-{index|category|detail|apply|share}.css 页面专属
├── js/
│ ├── nav-core.js $ / $all / safeStorage / ready 基础设施
│ ├── nav-theme.js 主题/背景/主色引擎 + 面板
│ ├── nav-festival.js ★ 节日/节气自动换装引擎(36 项)
│ ├── nav-fav.js / nav-search.js / nav-rating.js / nav-widgets.js / auth.js
├── sw.js Service Worker(缓存版本化)
├── manifest.json PWA manifest
└── img/ 默认图标等静态资源
```
---
## 2. 路由表(addon/haonav/route/app.php)
> 规则**不含** `/haonav` 前缀(外层 group 自动包裹)。URL 生成用 `url('haonav/xxx')` 或 `url('[haonav_site]', ['id' => $id])`,**不要写 `haonav/index/xxx` 三段式**(MultiApp 会剥首段导致对不上)。
### 前台
| 规则 | 控制器@方法 | name | 说明 |
|---|---|---|---|
| `index` | Index@index | haonav_index | 首页 |
| `category/:id` | Index@category | haonav_category | 分类页 |
| `site/:id` | Index@site | haonav_site | 详情页 |
| `search` / `suggest` / `rate` | Index@* | — | 搜索/建议/评分 |
| `rank` | Index@rank | — | 排行 |
| `submit` / `apply` | Index@* | — | 投稿 |
| `favicon` / `snapshot` | Index@* | favicons | 图标/快照代理 |
| `sw` | Index@sw | — | SW 输出 |
| `ad/click` | Index@adClick | — | 广告点击 |
| `sitemap(.xml)` | Index@sitemap | — | 站点地图 |
| `account/{login\|register\|logout\|profile}` | Account@* | — | 会员 |
| `widget/{hot\|weather}` | Widget@* | — | 小组件代理 |
| `favorite/{list\|add\|remove\|sort\|merge}` | Favorite@* | — | 云端收藏 API |
| `favorite/share` / `sharesave` | Favorite@* | — | 分享配置(登录) |
| `favorite/shared/:token` | Favorite@shared | — | 公开分享页 |
| `task/checklinks` / `task/autocheck` | Task@* | — | 巡检(需 token) |
### 后台(backend 组)
`dashboard / links / category / configs / ad / apply` + 各自 `:action` 子规则。
### 2.1 URL 生成规范(重点)
YwxApp 提供 **3 种**生成插件 URL 的方式,对应不同场景:
#### ① `url()` ThinkPHP 内置助手(推荐,name 化)
```php
// 方式 A:直接路径(规则须全字符匹配)
url('haonav/index') // → /haonav/index.html
url('haonav/category/5') // → /haonav/category/5.html
// 方式 B:用 route name(路由表里 ->name() 声明的)—— 带参数规则必须用 name
url('[haonav_site]', ['id' => 5]) // → /haonav/site/5.html
url('[haonav_category]', ['id' => 5])// → /haonav/category/5.html
// 附加 GET 参数
url('haonav/search', ['q' => 'haonav']) // → /haonav/search.html?q=haonav
```
**模板语法**:
```html
<a href="{$d.url|default=''}">...</a> <!-- 取控制器 assign 的 url 字段 -->
<a href="{:url('haonav/site', ['id' => $info.id])}">详情</a>
<a href="{:url('[haonav_site]', ['id' => $info.id])}">详情</a> <!-- 推荐 -->
```
#### ② `addon_url()` 框架插件助手(ywxapp/helper.php)
```php
addon_url('haonav', 'site/5'); // → /haonav/site/5
addon_url('haonav', '', ['page' => 2]);// → /haonav?page=2
addon_url('haonav', 'admin/index'); // → /haonav/admin/index
```
适用:跨插件、动态拼接、不依赖路由 name 的场景。
#### ③ 直接拼接(不推荐,仅 static 资源)
```php
// 仅限静态资源、JS 接口路径等无需经过路由解析的链接
'/static/haonav/css/nav-base.css'
```
#### ⚠️ 三大常见错误
| 错误写法 | 后果 | 正确 |
|---|---|---|
| `'haonav/index/rank'`(**三段式**) | MultiApp 剥首段后变成 `index/rank` → 对不上路由 → 404 | `'haonav/rank'`(两段:插件名+规则) |
| `url('haonav/category/5')`(带参数裸写) | 路由表规则 `category/:id` 必须有 name 才能把 5 填进路径段,否则退化为 `?id=5` | `url('[haonav_category]', ['id' => 5])` |
| `'addon_url'('haonav', '/index')`(重复斜杠) | 会生成 `/haonav//index`(无害但脏) | `addon_url('haonav', 'index')` |
#### 绑定域名下的 URL 行为
`url()` 和 `addon_url()` 都依赖 `request()->domain()`,**绑定域名后自动用绑定域名生成**:
```
未绑定:example.com/haonav/index.html
绑定后:hao.example.com/haonav/index.html(自动)
```
无需在模板里手写域名。
### 2.2 域名绑定机制(开发侧)
#### 配置文件 `config/app.php`
```php
'domain_bind' => [
'hao' => 'haonav', // 二级域名匹配
// 'hao.example.com' => 'haonav', // 完整域名匹配
],
```
#### 解析时序(ywxapp/middleware/MultiApp.php)
```
HTTP 请求进入
↓
1. 独立入口判断(如 haonav.php 存在 → 直接解析)
↓ 未命中
2. 域名绑定(domain_bind)
├── 完整域名命中 bind[host()] → 取 appName
├── 子域名命中 bind[subDomain()] → 取 appName
└── 通配 bind['*'] → 取 appName
↓ 未命中
3. URL 路径首段识别 → app_map / 多根查找
↓
resolve(appName) → 在 app_roots 中查找
├── prefix='app' → app/ 目录
└── prefix='addon' → addon/ 目录
```
`app_roots` 默认配置:
```php
'app_roots' => [
'app' => $app->getBasePath(), // app/
'addon' => $app->getRootPath() . 'addon/', // addon/
],
```
#### 多根查找的关键规则
- 裸 appName(如 `haonav`):按 roots 顺序找,**第一个匹配即用**(不是 fallback)
- 显式前缀(如 `addon:haonav`):**只在指定根找**,找不到直接 404
- 因此 `addon/haonav/` 存在 → 自动命中,无需额外配置
#### 调试技巧
```bash
# 1. 打印当前生效的域名绑定
php -r "echo json_encode(config('app.domain_bind'));"
# 2. 模拟请求命中哪个应用
php -r "
\$req = \think\facade\Request::create('http://hao.example.com/foo');
echo \$req->host() . PHP_EOL; // haon.example.com
echo \$req->subDomain() . PHP_EOL; // haon
"
# 3. 路由调试
访问 /haonav/test (Index::test 会 dump 当前路由表)
```
#### 完整域名 vs 子域名匹配的实际差异
```
请求:hao.example.com
完整域名模式 ('hao.example.com' => 'haonav')
host(true) = 'hao.example.com' ← 直接命中
子域名模式 ('hao' => 'haonav')
subDomain() = 'hao' ← 命中
host(true) = 'hao.example.com' ← 不会用作 key
```
两种方式**结果一致**,推荐**子域名模式**(部署同一证书多子域名复用、迁移服务器只改 DNS 即可)。
#### 绑定域名后的冗余首段兼容(框架层,MultiApp::stripBoundAppPrefix)
绑定域名后 URL **首段若与应用名相同**(即插件路径模式的旧链接,如 `docs.example.com/docs/xxx`),MultiApp 会把该段视为冗余前缀剥掉再分发——旧链接继续可用,不会 404。
```
docs.example.com/docs/xxx → 剥掉 docs → docs 应用内路由 xxx ✓(旧链接兼容)
docs.example.com/xxx → 首段 ≠ 应用名 → 不剥,直接路由 xxx ✓(新链接)
```
配套约定(以 docs 插件为参考实现):**插件前台链接生成走"动态前缀"**——
```php
// 插件前台基类(DocsFrontend::docsBase() 同款逻辑)
protected function addonBase(): string
{
// 1) 独立入口 public/<addon>.php → ''
$script = pathinfo((string) ($_SERVER['SCRIPT_FILENAME'] ?? ''), PATHINFO_FILENAME);
if ($script === '<addon名>') { return ''; }
// 2) domain_bind 命中本插件(支持 '<addon>' / 'addon:<addon>' 两种绑定值)→ ''
$bind = app()->config->get('app.domain_bind', []);
// ... 遍历 host(true) / subDomain() / '*' 三级 key 对比
return '/<addon url 前缀>'; // 3) 路径模式 → '/docs'、'/haonav' 等
}
```
- 模板:`href="{$docsBase}/xxx"`(基类统一 assign),**禁止硬编码 `/docs/`**
- 前端 JS:layout 注入 `window.DOCS_BASE = '{$docsBase}'`,JS 用 `BASE + '/xxx'` 拼接;解析 pathname 时先剥掉 `BASE` 段
- 后台模板无需处理(后台始终走主域名路径模式)
#### ⚠️ `$this->fetch()` 返回字符串,不是 Response 对象
框架(ThinkPHP)里控制器的 `fetch()` **返回渲染后的 HTML 字符串**,因此**不能链式调用 Response 方法**:
```php
// ❌ 报错:Call to a member function code() on string
return $this->fetch('read/notfound')->code(404);
// ✅ 用 response() 包装(第 2 参为状态码,第 3 参为 header)
return response($this->fetch('read/notfound'), 404);
// ✅ 需要自定义 Content-Type 时(如 sitemap)
return response($xml, 200, ['Content-Type' => 'application/xml; charset=utf-8']);
```
同理 `$this->fetch()` 也不能直接 `->header()` / `->contentType()`,一律经 `response()` 中转。
(直接 `return $this->fetch(...)` 时框架会自动包装成 200 HTML 响应。)
---
## 3. 控制器架构
### 3.1 前台骨架模式(Index.php)
`Index` 仅是 trait 组合壳 + 路由注释,**业务全在 12 个 trait**。新增前台功能 → 新建 trait → 挂到 Index 类 + 加路由。
```php
class Index extends FrontendBase
{
protected $noNeedLogin = ['*']; // 前台全放行(游客)
protected $noNeedVerify = ['*']; // 跳过用户组校验
use BaseTrait; // 共享 helper(siteConfig/assignSeo/assignAppearance...)
use HomeTrait; use SearchTrait; ... // 按职责
protected function initialize()
{
$this->assignAppearance(); // 外观配置注入所有页面
}
}
```
### 3.2 视图根切换(框架 FrontendBase)
插件上下文(`addon\` 命名空间)自动把 `view_path` 切到 `addon/haonav/view/frontend/`。`fetch()` 钩子叠加皮肤覆盖层(SkinOverlay)、配色变量(SkinVariables,`<style id="ywx-skin-vars">`)与风格切换器注入。
### 3.3 BaseTrait 关键 helper
| 方法 | 用途 |
|---|---|
| `siteConfig()` | 基础配置数组 → assign `$config` |
| `seoConfig()` / `assignSeo()` | SEO TDK → assign `$seo` / `$siteUrl` |
| `assignAppearance()` | ★ 外观(bg_image/bg_mask/bg_random/festival)→ assign `$appearanceJson`(raw JSON) |
| `assignAds()` | 广告位分组 → assign `$ads` |
| `recordClickStat($linkId, $cid)` | (link_id, date, hour) 聚合表 `wxapp_haonav_click_stats`,UPSERT 写入失败静默 |
| `serveFile()` / `serveDefault()` | 静态资源输出(带缓存头) |
---
## 4. 配置系统与自愈机制
配置表:`wxapp_haonav_config`(name/group/title/tip/type/value/content)。
### 4.1 三层自愈(Configure 模型)
| 方法 | 作用 |
|---|---|
| `ensurePk()` | 老库 id 无自增主键 → 运行时修复(解决 1364 错误) |
| `ensureRebateConfig()` | 返利 3 项补种 |
| `ensureDeadlinkConfig()` | task 3 项补种 |
| `ensureAppearanceConfig()` | 外观 4 项补种(bg_image/bg_mask/bg_random/festival_theme) |
调用点:后台 `Configs::index()` 首屏触发,INSERT 按 name 判重幂等。**新增配置项照此模式**:模型加 `ensureXxxConfig()` → Configs::index 调用 → BaseTrait::assignXxx 注入。
### 4.2 前端消费方式
`styles.html` 输出:
```html
<script>window.NavAppearance = {$appearanceJson|default='null'|raw};</script>
```
JS 侧读 `window.NavAppearance.{image,mask,random,festival}`。
---
## 5. 视图层规范
### 5.1 ⚠️ think-template include 传参陷阱(曾致 500)
```html
<!-- ✗ 禁止:跨模板作用域传参不可靠,抛 Undefined variable → HTTP 500 -->
{include file="public/styles" pageCss="index" /}
<!-- ✓ 正确:无参 include + 页面自行 link -->
{include file="public/styles" /}
<link rel="stylesheet" href="/static/haonav/css/nav-page-index.css">
```
Controller 级 `assign()` 是全局作用域,include 的子模板可正常读取(`$seo`、`$config`、`$appearanceJson` 均此机制)。
### 5.2 CSS 拆分架构
| 文件 | 职责 | 加载方 |
|---|---|---|
| `nav-base.css` | `:root` 变量、dark 变量、重置、navbar、搜索、卡片、排行、响应式 | styles.html(全页面) |
| `nav-theme.css` | 主题面板、背景遮罩 `body.nav-bg-img::before` | styles.html |
| `nav-festival.css` | 粒子 `.nav-fw`、徽章 `.nav-festival-badge` | styles.html |
| `nav-page-*.css` | 页面专属(widget/倒计时/tabs...) | 各页面手动 link |
CSS 变量:`--nav-primary` / `--nav-primary-2` / `--nav-bg` / `--nav-card` / `--nav-card-text` / `--nav-muted` / `--nav-border` / `--nav-tag-bg` / `--nav-bg-mask`。
### 5.3 JS 模块依赖链
```
nav-core.js($ / safeStorage / ready)← 必须最先
├─ nav-theme.js(主题引擎,读 window.NavAppearance)
│ └─ nav-festival.js(节日引擎,读 NavAppearance + localStorage)
├─ nav-fav.js / nav-search.js / nav-rating.js / nav-widgets.js
└─ auth.js
```
页面引入顺序(body 尾):`nav-core.js` → `nav-theme.js` → `nav-festival.js` → 业务 JS。
---
## 6. 主题 / 背景 / 节日引擎(核心机制)
### 6.1 存储值协议(localStorage `haonav_bg`)
| 值 | 含义 |
|---|---|
| `''`(无) | 默认(回落到站点配置→默认渐变) |
| `grad:<id>` | 预设渐变(`grad:green` 等,共 10 组) |
| `img:<url>` | 自定义图片(body 加 `nav-bg-img` 类触发遮罩层) |
| `linear-gradient(...)` | 旧版裸值(兼容层自动识别) |
`haonav_primary`(id)、`haonav_theme`(light/dark/auto)、`haonav_festival_off`(访客关闭节日)。
### 6.2 背景优先级链
```
访客 localStorage > 节日主题(nav-festival.js)> 站点 bg_image > 站点 bg_random > :root --nav-bg
```
### 6.3 节日引擎(nav-festival.js)
- `LUNAR`:农历→公历静态表(spring/dragon/midautumn/qixi/chongyang/xiaonian,2026-2036),**数据为公开农历表整理,生产请以紫金山天文台/香港天文台校准**
- `FESTIVALS`:36 项定义(24 节气 `key:'m-d'` 公历固定 + 农历节日 `lunar:+offset` + `span` 持续天数)
- `detect()`:数组顺序遍历,**靠前者优先**(同日多条时决定胜出)
- 命中后:`bg`(渐变或 url)→ body 内联背景;`p/s` → `--nav-primary(-2)`;`icon/count` → 粒子;`suffix` → 徽章文案
- **时间源**:`new Date()` 浏览器本地时间(跨时区偏移 1 天,如需全球一致可改 `Intl.DateTimeFormat('en-CA',{timeZone:'Asia/Shanghai'})`)
### 6.4 扩展:新增节日 / 节气
```js
// 1. LUNAR 加农历映射(农历节日才需要)
LUNAR.newfest = { '2026': '8-15', '2027': '...', ... };
// 2. FESTIVALS 按日历位置插入(数组靠前优先)
{ id: 'newfest', name: '新节', lunar: 'newfest', offset: 0, span: 0, suffix: '快乐',
bg: 'linear-gradient(135deg,#xxx 0%,#yyy 100%)', p: '#xxx', s: '#yyy', icon: '🌸', count: 12 },
// 3. 公历固定日期直接用 key
{ id: 'foo', name: 'Foo节', key: '3-12', span: 0, suffix: '', bg: '...', ... },
// 4. sw.js 缓存版本 +1(否则浏览器拿旧 JS)
var CACHE = 'haonav-v7';
```
---
## 7. Service Worker 缓存策略(sw.js)
- 版本号 `CACHE`:**每次改动 css/js 必须升版本**(v6 当前),activate 时自动清旧缓存
- 静态资源(`/static/haonav/*`)+ favicon/snapshot:**缓存优先,后台更新**
- 页面(`/haonav/*`):**网络优先,离线回退缓存**
- 不缓存:`favorite|widget|task|suggest` 接口
---
## 8. 数据表
| 表 | 说明 |
|---|---|
| `wxapp_haonav_links` | 链接(含 pinyin 搜索串、click_count、快照路径) |
| `wxapp_haonav_category` | 分类(pid 两级、sort、status) |
| `wxapp_haonav_config` | 配置(见 §4) |
| `wxapp_haonav_apply` | 前台投稿 |
| `wxapp_haonav_ad` | 广告位 |
| `wxapp_haonav_favorite` | 云端收藏(uid 维度) |
| `wxapp_haonav_favorite_share` | 收藏夹分享(token) |
| `wxapp_haonav_click_stats` | 点击聚合(link_id, cid, date, hour, clicks UPSERT) |
> 老库结构问题由 `BaseModel::ensureAutoIncrementPk()` 运行时自愈。
---
## 9. 开发流程约定
### 9.1 新增前台页面
1. `controller/trait/` 建 trait(或挂到现有 trait)
2. `route/app.php` 加规则(带参数加 `->name()`)
3. `view/frontend/index/` 建模板:`{include file="public/styles" /}` + 手动 link `nav-page-<name>.css`
4. body 尾按序挂 JS(core → theme → festival → 业务)
5. `sw.js` PRECACHE 增文件 + 版本 +1
### 9.2 新增后台功能
`controller/backend/` 建类继承 `HaonavBackend`,路由加 `backend` 组,视图 `view/backend/`。
### 9.3 新增配置项
模型 `ensureXxxConfig()` → `Configs::index` 调用 → 需要前台消费时在 `BaseTrait` 加 assign 注入 → 前端 `window.NavAppearance` 或模板变量读取。
### 9.4 静态资源改动 checklist
- [ ] `sw.js` `CACHE` 版本 +1
- [ ] 新文件加入 `PRECACHE`
- [ ] 页面 link/script 引入顺序:NavAppearance 注入(head)先于 nav-theme.js(body 尾)
---
## 10. 已知限制与历史坑
| 问题 | 状态 | 说明 |
|---|---|---|
| include 传参 500 | 已修 | page-*.css 手动 link 方案固化,勿回退 |
| 节日时区偏移 | 已知 | 浏览器本地时间判定,海外访客 ±1 天 |
| 农历换算表精度 | 已知 | 静态表 2026-2036,2030+ 建议官方校准 |
| SW 缓存旧资源 | 依赖版本号 | 改动必须升 CACHE |
| 裸路由前缀吞噬 | 已修 | 后台裸规则必须 `->completeMatch(true)` 强制完整匹配 |
---
## 11. 相关文档
- 站长使用文档:[haonav_user_guide.md](haonav_user_guide.md)
- 插件开发指南:[../插件开发指南.md](../插件开发指南.md)
- 框架开发文档:[../开发文档.md](../开发文档.md)
- 插件机制对比(Discuz):[../插件机制对比_Discuz.md](../插件机制对比_Discuz.md)
+342
View File
@@ -0,0 +1,342 @@
# haonav 网址导航 — 使用文档
> 面向站长/管理员。开发者请看 [haonav_dev_guide.md](haonav_dev_guide.md)。
- 插件标识:`haonav`
- 版本:1.0.9
- 访问入口:`https://你的域名/haonav`(前台)/ `https://你的域名/haonav/dashboard`(后台)
- 依赖:YwxApp 框架 ≥ PHP 8.0、MySQL 5.7+
---
## 1. 功能总览
| 模块 | 说明 |
|---|---|
| 网址导航首页 | 顶级分类聚合 + 子分类 + 每分类精选链接 + 热门/推荐 |
| 站内搜索 | 关键词/拼音搜索 + 搜索建议(suggest)+ 多引擎跳转 |
| 热门排行 | 按点击量 Top 50 |
| 网址详情页 | 快照预览、评分、JSON-LD 结构化数据(SEO) |
| 分类浏览 | 一级/二级分类,URL 规则化(`/haonav/category/{id}.html`) |
| 申请收录 | 前台投稿 → 后台审核 |
| 广告位 | 首页/详情页广告位管理 + 点击统计 |
| 我的收藏 | 本地收藏(localStorage)+ 云端同步(登录)+ 收藏夹分享页 |
| 实时小组件 | 实时热搜 / 天气(服务端代理 + 缓存) |
| 主题个性化 | 浅色/暗色/跟随系统 + 5 组主色 + 10 组背景渐变 + 自定义背景图 + 随机换色 |
| 节日/节气换装 | **36 项自动检测**(24 节气 + 传统节日 + 公历节日),后台可关,访客可关 |
| PWA 离线 | manifest + Service Worker 缓存(支持添加到主屏幕、离线访问) |
| SEO | sitemap.xml/html、canonical、JSON-LD、拼音搜索 |
| 死链巡检 | 计划任务全量检测 + 前台惰性巡检兜底,可自动下线/恢复 |
---
## 2. 安装与启用
1. 将插件目录放入 `addon/haonav/`
2. 后台 → 应用管理 → 找到「网址导航」→ 安装/启用
3. 首次访问后台配置页时,数据库会自动补种默认配置行(含表主键自愈),无需手工执行 SQL
4. 前台访问 `/haonav` 即可看到空站点,进入后台开始录入
---
## 3. 快速上手(5 分钟建站)
1. **建分类**:后台 → 网址导航 → 分类管理 → 新增一级分类(如「常用工具」),可再挂二级分类
2. **录链接**:链接管理 → 新增 → 选分类、填名称/URL/描述、可选设「热门」「推荐」
3. **看前台**:`/haonav` 首页即展示;点击量自动累计进排行榜
4. **做 SEO**:后台 → 网址导航配置 → 填 SEO 标题/关键词/描述
5. **(可选)开启外观**:同一配置页「外观」分组设置背景图/随机换色/节日换装
---
## 4. 后台管理
### 4.1 数据概览(仪表盘)
`/haonav/dashboard` — 链接/分类/收藏/点击量汇总统计 + 点击热力图。访问时自动为存量链接回填拼音搜索串(每次 300 条,幂等)。
### 4.2 分类管理
- 一级/二级分类、排序(sort 越大越靠前)、启用/禁用
- 删除分类不影响已挂链接(链接保留待重新归类)
### 4.3 链接管理
- 字段:名称、URL、分类、描述、关键词、热门/推荐标记、排序、状态
- 自动抓取 favicon(`/haonav/favicon/...` 代理输出)与网站快照
- **回收站**:删除先进回收站,可恢复/彻底删除
- **死链巡检**:见 §8 计划任务
### 4.4 申请审核(Apply)
前台 `/haonav/apply` 投稿后进入待审列表,可一键通过(转为正式链接)/驳回。
### 4.5 广告位(Ad)
- 按 position 分组投放(如 `home_top`、`detail_side`)
- 支持图片/文字广告、投放时段、排序
- 点击经 `/haonav/ad/click` 跳转并计数
### 4.6 配置(Configs)
分组管理所有配置项,保存即时生效。见下节明细。
---
## 5. 配置项详解
后台 → 网址导航配置。首次访问自动补齐缺失项。
| 分组 | 配置项 | 默认 | 说明 |
|---|---|---|---|
| 基础 | `default_engine` | baidu | 搜索框默认引擎 |
| 基础 | `enable_submit` | 1 | 前台投稿开关 |
| 基础 | `enable_hot_api` | 0 | 实时热搜组件开关 |
| SEO | `seo_title` / `seo_keywords` / `seo_description` | — | 全站 TDK |
| 返利 | `enable_rebate` | 0 | 命中域名自动拼接返利 PID |
| 返利 | `rebate_param` | pid | 返利参数名 |
| 返利 | `rebate_domains` | taobao.com,... | 返利适用域名列表 |
| task | `deadlink_auto` | 0 | 死链连续失败达阈值自动下线 |
| task | `deadlink_threshold` | 2 | 连续失败次数阈值 |
| task | `deadlink_recover` | 0 | 恢复可达后自动重新上线 |
| **外观** | `bg_image` | 空 | 全站默认背景图 URL(空=渐变) |
| **外观** | `bg_mask` | 35 | 背景图遮罩不透明度 0-90(保证文字可读) |
| **外观** | `bg_random` | 0 | 游客随机渐变背景(每次刷新随机) |
| **外观** | `festival_theme` | 1 | 节日/节气自动换装总开关 |
---
## 6. 前台功能说明
| 页面 | URL | 说明 |
|---|---|---|
| 首页 | `/haonav`(`/haonav/index.html`) | 分类聚合 + 热门 18 + 推荐 10 |
| 搜索 | `/haonav/search?q=关键词` | 站内 + 引擎外跳 |
| 排行 | `/haonav/rank` | 点击量 Top 50 |
| 详情 | `/haonav/site/{id}.html` | 快照、评分、JSON-LD |
| 分类 | `/haonav/category/{id}.html` | 单分类列表 |
| 申请 | `/haonav/apply` | 收录/广告合作投稿 |
| 分享页 | `/haonav/favorite/shared/{token}.html` | 只读收藏夹(noindex) |
| 站点地图 | `/haonav/sitemap.xml` | XML + HTML 双格式 |
| Service Worker | `/haonav/sw` | PWA 离线支撑 |
**会员**:`/haonav/account/login|register|logout|profile`。登录后「我的导航」自动云端同步收藏;未登录自动回退本地 localStorage。
---
## 7. 主题与个性化
前台导航栏 🎨 按钮打开主题面板:
| 功能 | 说明 | 记忆 |
|---|---|---|
| 模式 | ☀️ 浅色 / 🌙 暗色 / 🌗 跟随系统 | localStorage |
| 主色 | 5 组色板(紫/绿/蓝/粉/灰) | localStorage |
| 背景 | 10 组渐变色点 + 🎲 随机换色 + 自定义图片 URL | localStorage |
| 恢复默认 | 一键清除以上所有个人偏好 | — |
**自定义背景图**:面板中「粘贴图片地址设为背景」→ 应用(或回车)。图片以 cover 全屏铺满,自动叠加白色遮罩(暗色模式自动改压暗),遮罩强度由后台 `bg_mask` 控制。
**优先级链**(高 → 低):
```
访客自选背景/主色 > 节日/节气主题 > 站点背景图(bg_image) > 随机换色 > 默认渐变
```
---
## 8. 节日 / 节气自动换装
### 8.1 内置条目(共 36 项)
- **24 节气**:立春/雨水/惊蛰/春分/清明/谷雨/立夏/小满/芒种/夏至/小暑/大暑/立秋/处暑/白露/秋分/寒露/霜降/立冬/小雪/大雪/冬至/小寒/大寒 — 公历固定日期,当天自动换季相配色 + 应季粒子(🌱☀️🍂❄️)
- **传统节日(农历)**:除夕 / 春节(初一至初三)/ 元宵 / 小年 / 端午 / 七夕 / 中秋 / 重阳 — 内置 2026-2036 公历换算表
- **公历节日**:元旦 / 情人节 / 国庆(10/1-10/7)/ 万圣节 / 圣诞(12/24-12/26)
### 8.2 效果
命中当天自动:换节日背景渐变 → 换全站主色 → 飘落装饰粒子(emoji 随机大小/速度)→ 右上角徽章(如「🏮 春节快乐」「⚡ 惊蛰」)。
- **时间判定**:浏览器本地时间(`new Date()`)。跨时区访客可能偏移 1 天,国内用户无感
- **访客关闭**:点徽章右上 × 永久关闭(localStorage)
- **站长关闭**:后台 → 外观 → 节日主题自动换装 → 0
### 8.3 「节日不生效」排查
依次检查(Console 执行):
```js
localStorage.getItem('haonav_festival_off') // '1' = 曾点过关闭 → removeItem 后刷新
localStorage.getItem('haonav_bg') // 有值 = 自选背景覆盖了节日背景
window.NavAppearance.festival // 0 = 后台总开关关闭
```
Service Worker 旧缓存干扰时:F12 → Application → Clear site data。
---
## 9. SEO 能力
- `sitemap.xml` / `sitemap.html` 双格式自动生成
- 详情页输出 JSON-LD(WebSite + SearchAction 搜索直达框)
- 全站 canonical 链接
- 链接支持自定义 keywords/description
- 拼音搜索:中文名自动转拼音串,支持拼音首字母检索
---
## 10. 死链检测(计划任务)
两种模式可同时启用:
### 10.1 定时全量(推荐 cron)
```cron
0 3 * * * curl -s "https://你的域名/haonav/task/checklinks?token=你的TOKEN" > /dev/null
```
每日凌晨全量检测所有启用链接。
### 10.2 惰性巡检(无 cron 兜底)
前台页面访问时异步触发 `/haonav/task/autocheck`,带缓存锁限频,自动分批检测。
### 10.3 自动下线/恢复
开启「死链自动下线」后:连续失败 ≥ 阈值 → 自动禁用;开启「恢复自动上线」后:重新可达 → 自动启用(仅对检测器下线的链接生效,手动禁用不受影响)。
---
## 11. 常见问题 FAQ
**Q1:页面 500?**
确认没有模板 include 传参(`{include file="..." var="x" /}` 在 think-template 跨模板作用域不可靠,page-*.css 由各页面手动 `<link>` 引入)。
**Q2:改了背景图后台配置前台不生效?**
浏览器/SW 缓存。F12 → Application → Clear site data;或等 sw 版本升级自动刷新。
**Q3:游客反馈"背景一直没变过"?**
该游客曾点过节日徽章 × 或自选过背景。让其点击面板「↺ 恢复默认」。
**Q4:拼音搜索搜不到?**
旧数据拼音串未回填。访问一次后台仪表盘即自动补(每批 300 条),或多次刷新。
**Q5:收藏在不同设备不同步?**
收藏分本地(未登录)/云端(登录)两套。登录后自动 merge 并以云端为准。
---
## 12. 绑定域名访问
希望独立域名(如 `hao.example.com`)直接访问网址导航,而不是 `example.com/haonav`?YwxApp 通过 `config/app.php` 的 `domain_bind` 支持。
### 12.1 三种使用场景
| 场景 | 配置方式 | URL 形态 |
|---|---|---|
| 默认(路径访问) | 不配 | `example.com/haonav` |
| 二级域名绑定 | `'hao' => 'haonav'` | `hao.example.com`(自动剥 `hao.`) |
| 完整域名绑定 | `'hao.example.com' => 'haonav'` | 直接访问 `hao.example.com` |
### 12.2 启用步骤(5 步)
#### ① 修改 `config/app.php`
```php
'domain_bind' => [
'hao' => 'haonav', // 二级域名 → 插件名(推荐)
// 'hao.example.com' => 'haonav', // 完整域名 → 插件名
],
```
#### ② Web 服务器指向同一项目目录
绑定域名访问本质上是同一份代码,让 DNS 把 `hao.example.com` 解析到与 `example.com` 相同的服务器 / 同一 Web 根。
**Nginx**(在主 server 块外新增或合并):
```nginx
server {
listen 80;
server_name example.com hao.example.com; # 多 server_name 即可
root /www/ywxapp_dev/public;
index index.php;
# 其余 fastcgi / rewrite 规则同主站
location ~ \.php$ {
fastcgi_pass unix:/run/php/php-fpm.sock;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
}
```
**Apache**:`ServerName example.com` → `ServerAlias hao.example.com`。
#### ③ DNS 解析
到域名服务商把 `hao.example.com` 用 A/CNAME 记录指向主站 IP,TTL 选短值便于调试。
#### ④ 清框架运行时缓存
```bash
rm -rf runtime/cache/*
```
#### ⑤ 测试
浏览器访问 `http://hao.example.com` → 应直接呈现 haonav 首页(不再带 `/haonav` 前缀)。
### 12.3 「绑定后无法访问」逐项排查
按下面顺序逐项确认(90% 的问题在第 1/2 步):
| 序号 | 排查点 | 自检命令 / 现象 |
|---|---|---|
| 1 | **DNS 是否解析到服务器** | `ping hao.example.com` 应返回主站 IP;不通就是 DNS 没配 |
| 2 | **Web Server 是否监听该域名** | `curl -H "Host: hao.example.com" http://服务器IP/` 不应返回 404/连接拒绝;Nginx 看 `server_name` 包含子域名,Apache 看 `ServerAlias` |
| 3 | **`config/app.php` 是否正确** | `php -r "print_r(config('app.domain_bind'));"` 确认配置生效(务必重启 php-fpm) |
| 4 | **缓存清理** | 改完配置删 `runtime/cache/*`;TP 路由缓存若有也清 |
| 5 | **插件名与 bind 值一致** | `domain_bind['hao']='haonav'` 中 `haonav` 必须与 `addon/haonav/info.php` 的 `name` 完全一致(区分大小写) |
| 6 | **`addon/haonav/` 存在且有 route** | 验证 `addon/haonav/route/app.php` 可被框架 include |
| 7 | **完整域名是否带协议** | `'hao.example.com'` 不要写成 `'http://hao.example.com'` |
| 8 | **HTTPS** | 绑定后若浏览器强制 HTTPS,请确保子域名也有证书(Let's Encrypt 用 `certbot --expand`) |
| 9 | **SSL 泛域名** | 多子域名绑同一站点,用通配证书 `*.example.com` 最省事 |
| 10 | **框架缓存类** | 部分主机有 OPcache,改完 `config/app.php` 重启 `php-fpm` 才生效 |
**最常见两大坑**:
```
坑 ①:DNS 已解析但 Web Server 没加 server_name → 访问显示「无法访问此网站」/ 默认页
坑 ②:Web Server 已加但 domain_bind 没写 / 拼写错 → 显示「路由不存在 404」
```
### 12.4 多域名绑定同一插件
```php
'domain_bind' => [
'hao' => 'haonav', // hao.example.com
'nav' => 'haonav', // nav.example.com
'hao.dev.example.com' => 'haonav', // 测试子域
],
```
不同域名可分别承担同一插件的「访问入口」与「SEO 镜像」,但**避免一站多域名导致 SEO 重复**——通过 `seo_title` 区分或加 `<link rel="canonical">` 指向主域。
### 12.5 绑定域名后的相对路径影响
| 场景 | 行为 |
|---|---|
| 模板用 `url('haonav/index')` | 生成的 URL 已自动带域名上下文 → `https://hao.example.com/haonav/index.html` |
| 模板用绝对路径 `/static/...` | 不变(推荐) |
| 模板用相对路径 `static/...` | ⚠️ 子域名访问时可能找不到资源 → **统一用绝对路径** |
| 前端 JS 中 `location.href = '/xxx'` | 浏览器会替换为当前域名下的绝对路径,安全 |
| Service Worker | 缓存清单使用绝对路径(已配置),绑定域名后仍正常工作 |
---
## 13. 相关链接
- 开发文档:[haonav_dev_guide.md](haonav_dev_guide.md)
- 框架插件机制:[../插件开发指南.md](../插件开发指南.md)
- 部署指南:`docs/deploy/`
+536
View File
@@ -0,0 +1,536 @@
# IoT 插件接口与 MQTT 主题参考
> 本文以**代码为准**(`addon/iot/route/app.php`、`controller/api/Client.php`、`controller/api/Mqtt.php`、`service/*.php`、`firmware/water_temp/water_temp.ino`)。
> 与 `device_register_mqtt.md` 冲突处,以本文为准(差异见文末「已发现的不一致」)。
---
## 一、通用约定
### 1.1 基址与鉴权
| 项 | 值 |
|---|---|
| App 接口前缀 | `https://{host}/iot/api/client/*`(接入层:绑定/共享/凭证/命令);时序·配置·告警在 `https://{host}/iot/api/monitor/*`(见 §2.4,旧 `client/*` 路径仍作兼容别名) |
| 设备 / 公开接口 | `https://{host}/iot/api/{deviceActivate,status,publish}` |
| 后台接口前缀 | `https://{host}/iot/backend/*` |
| App 鉴权 | Header `Authorization: Bearer {会员登录 token}`(框架 JWT,`ApiBase` 统一拦截,未登录 HTTP 401) |
| 后台鉴权 | `verifyAuth()` + `tokenParse()`,Bearer JWT |
| 免登录白名单 | 仅 `Client::deviceActivate`;`Mqtt` 控制器整个 `noNeedLogin = ['*']` |
### 1.2 响应格式(⚠️ 有两套,字段名不同)
**A. `Client.php`(App 全部接口)→ `apiSuccess()` / `apiError()`**
```json
{ "code": 0, "msg": "success", "data": {}, "timestamp": 1789000000 }
```
- `code=0` 成功 / `1` 业务失败 / `401` 未登录(HTTP 同为 401)
- 字段名是 **`msg`**,不是 `message`
- 携带 token 时附加 `tokens.access_token` / `tokens.access_expired` / `tokens.refresh_token`
**B. `Mqtt.php`(`status` / `publish`)→ `success()` / `error()`**
```json
{ "code": 0, "message": "success", "data": {}, "timestamp": 1789000000, "count": 12 }
```
- 字段名是 **`message`**,分页字段是 `count`
- 发布失败时 `error()` 之后**仍会继续往下执行**(`Mqtt::publish` 未 return),属已知小 bug
> ⚠️ `$this->result->setCount(n)->apiSuccess($list)` 这个组合**不会输出 `total`**:`apiSuccess()` 只认第三个参数 `$count`,而 `setCount()` 设的是 `$this->Total`(只有 HTML 版 `success()` 会用)。
> ⇒ App 端不要依赖这些接口的 `total`,条数从 `data` 数组长度取。
---
## 二、App / 会员 REST API(前缀 `/iot/api/client/`;时序·配置·告警 `/iot/api/monitor/*`)
### 2.1 设备发现与绑定
| 方法 | 路径 | 参数 | 说明 |
|---|---|---|---|
| GET | `devices` | — | 我的 MQTT 连接记录(`iot_connection`,按 `username = 会员账号` 查,上限 200 条) |
| GET | `subscriptions` | — | 我的订阅主题列表,`data:{list,total}` |
| GET | `myDevices` | — | **我的设备**(主人 + 被共享,含 `role`、在线态、影子、**内联位置配置** `sensors/cfg_ver/sync_ver/applied`;已剔除 secret) |
| POST | `bindDevice` | `sn`(必填)、`pair_code`(设备设了配对码则必填)、`title`、`model`、`room` | 绑定;已激活设备不重置凭证;未激活且无配对码时绑定即激活 |
| POST | `unbindDevice` | `uuid`(必填) | 解绑,**会重置设备密钥** |
| POST | `shareDevice` | `device_id`、`account`(对方会员账号)、`perm`(1 只读 / 2 可控,默认 2) | 仅主人可操作 |
| POST | `revokeShare` | `device_id`、`member_id` | 撤销共享 |
| GET | `shareList` | `device_id` | 共享成员列表(仅主人) |
| POST | `resetSecret` | `uuid` | 重置密钥,新明文仅返回一次 |
> **三个标识符别混用**(各家接口用的不是一个东西):
>
> | 标识符 | 是什么 | 哪些接口用 |
> |---|---|---|
> | `sn` | `iot_device.sn` 列,**设备序列号** | `bindDevice`、`deviceActivate` |
> | `uuid` | `genUuid()` 生成,形如 `dev_` + 16 位 hex,设备 MQTT 连接用户名 | `unbindDevice`、`deviceState`、`deviceConfig`、`saveDeviceConfig`、`sendCommand`、`deviceLogs`、`publishOta`、所有 `/devices/{uuid}/…` 主题 |
> | `device_id` | `iot_device.id`(自增整数) | `shareDevice`、`revokeShare`、`groupAddDevice`、`groupRemoveDevice` |
>
> **`sn` 是不是就是 mac?** 看设备怎么入的库:
> - **即插即用注册**(当前固件走的路径,`registerByMac()`):插入时 `'sn' => $mac, 'mac' => $mac` → **`sn` 就等于纯 mac**(12 位小写 hex、去冒号)。
> - **后台预登记**(`register()`):`sn` 是手填的序列号(≤64 字符任意),真 MAC 存在独立的 `mac` 列 → 此时**必须传 `sn`**。
>
> ⚠️ `bind()` 只按 `sn` 列精确查,**没有 `mac` 兜底**(`register()` / `registerByMac()` 有 `where('mac', …)` 兜底)。
> ⚠️ 更危险:`bind()` 查不到时**直接新建一台设备**(`sn` 填你传的值、`member_id` 填你)——sn 传错不报错,会静默建出「空壳设备」,而真设备仍是 `member_id=0`(看起来绑定成功,实际设备永远不在线)。
>
> ⚠️ `myDevices` 的返回:主人侧是**整行减 `secret`**(含 `pair_code`、`activate_token`、`member_id`、`cfg_ver` 等);共享侧(`sharedToMe()`)是 12 字段白名单(`sn/uuid/title/model/room/role/perm/online/shadow/topics/…`,**无 `dev_type`/`ver`/`mac`**)→ App 统一渲染时要按缺失字段兜底。
>
> ✅ **列表已内联位置配置**(`DeviceService::attachSensorConfig()`,2026-09-14 起):每台设备(**含共享**)都带
> `sensors:[{index,alias,probes,upper,lower,gap,enabled}]` 与 `cfg_ver/sync_ver/applied`。字段与
> `deviceConfig` 接口**完全一致**(同一 `SensorConfigService::format()` 出口,见 `getAll()`)
> ⇒ App 拉一次设备列表即可画骨架(离线/首屏可用、骨架屏好做),省掉 `deviceConfig` 那次往返;
> 旧服务端不返回该字段(`sensors` 缺失或为空)时,App 回退到 `deviceConfig` 即可,新旧服务端双向兼容。
>
> ⚠️ 列表里的 `pins` 是 `iot_device.pins` 的 **CSV 字符串**(`"3,4,10"`),与 `deviceConfig` 下发的
> `pins`(数组)**不同型** —— 别把列表里的 `pins` 直接当数组用(App 端 `applyPins()` 只从
> `deviceConfig`/`/status` 取,就是为避开这个差异)。
>
> ⚠️ 实现固定 **2 条 SQL**(`whereIn(uuid)` 批量后按 uuid 分组),**不要**改成在循环里逐台
> `SensorConfigService::get()` —— 那是 N+1,设备一多就把列表接口拖垮。
### 2.2 连接凭证与指令
| 方法 | 路径 | 参数 | 返回 |
|---|---|---|---|
| POST | `mqttTicket` | — | `{ticket(48 位 hex), account, expire_in:60, expires_at}`;顺带自愈 ACL(`resyncMemberAcl` + `resyncMember`) |
| POST | `sendCommand` | `uuid`、`payload`(JSON 字符串)、`qos`(0/1/2,默认 0) | 写入 `/devices/{uuid}/cmd`,`retain=0` |
| GET | `deviceState` | `uuid` | `{uuid, online, shadow}`(shadow = 最后一条 retained `state`) |
### 2.3 传感器配置
| 方法 | 路径 | 参数 | 返回 |
|---|---|---|---|
| GET | `deviceConfig` | `uuid` | `{cfg_ver, sync_ver, sync_at, applied, pins:[3,4,10], sensors:[{index,alias,probes,upper,lower,gap,enabled}], uuid}` |
| POST | `saveDeviceConfig` | `uuid`、`sensors`(数组或 JSON 字符串) | **全量覆盖**语义,保存即 retained 下发 |
>
> 🔑 **位置序号键统一为 `index`**(2026-09-15):`sensors[]` 元素 = `{index,alias,probes,upper,lower,gap,enabled}`。
> 服务端 `SensorConfigService::normalize()` 同时兼容旧键 `i` 与列名 `pos_index`(老 App 提交不会错位),
> 但**新代码一律只发 `index`**。
> ⚠️ 改这个键名必须同步三处消费方:`SensorPointService::capture()`(按位置取阈值复核)、App `device-panel`、
> 后台 `view/backend/admin/device.html` —— 少改一处,位置序号会**静默变 `0`**(不报错):
> 多个位置挤到同一序号,影子数据全填到位置 0,且保存时被「全量覆盖 + 同序号后写覆盖」合并成**一个位置**。
> ⚠️ 别与**下行 config 报文里的序号键**混为一谈:报文里它同样叫 `index`,而且**只有这一个键**
> (v1.7.0 的「index+i 双发」已于 2026-09-15 废除)。唯 `data[]`(≤1.4.0 老固件专用副本)仍用旧键 `i`,
> 改它等于让老设备一个位置都不启用(见 §5.2)。
- `applied = (sync_ver >= cfg_ver)`;`false` 表示设备尚未回 `config_ack`(离线,上线即生效)
- `saveDeviceConfig` 需 `PERM_CTRL`(只读共享会报「无权限修改该设备配置」)
- `pins`:**总线 GPIO 表,下标 = 位置序号**(对应 `iot_device.pins`)。取自设备上线时
`status` 报文里的 `pins`(v1.7.0+),服务端缓存后下发;设备从没上报过(≤1.6.2 老固件)
或从没上线过则为 `[]` —— 此时 App 靠 state 报文里的 `pin` 自行补齐显示
### 2.4 实时曲线与时序聚合(前缀 `/iot/api/monitor/*`)
> ⚠️ **三个接口返回结构互不相同**,这是 App 端最容易踩的坑:`sensorSeries` 回**原始稀疏点**,
> `sensorAvg` / `recentAvg` 回**聚合值**。拿 series 的键(`temp`/`interval`/`temp_diff`/`probes`/`alert_mask`)
> 去读聚合返回 ⇒ 键不存在,曲线**全是 `0` 和 `[]`**,而数据库里明明有数据(`ts` 两边都有,所以时间轴是对的)。
> 症状口诀:**「时间点对、温度全 0」= 读错键,不是没数据。**
| 方法 | 路径 | 参数 | 返回 `data` |
|---|---|---|---|
| GET | `sensorSeries` | `uuid`、`pos=0`、`from`、`to`、`limit=500`(钳 [1,2000])、`debug=1?` | `{uuid,pos,from,to,count,points}` |
| GET | `sensorAvg` | `uuid`、`pos=0`、`step=60`(钳 [60,86400])、`window=30`、`from`、`to`、`limit=720`、`debug=1?` | `{uuid,pos,step,from,to,count,points}` |
| GET | `recentAvg` | `uuid`、`window=10`(钳 [1,1440] 分钟)、`pos`(默认 `-1` = 全部位置) | `{uuid,window_min,from,to,samples_total,positions}` |
**① `sensorSeries` → `points[]`:原始稀疏点(细粒度,逐条上报)**
```json
{ "ts": 1789700400, "interval": 120, "temp": 26.5, "temp_diff": 0.2,
"probes": [ { "id": "A3F2", "temp": 26.5 } ], "alert_mask": 0 }
```
- `interval` = 本条覆盖秒数(曲线有效期 `[ts, ts+interval]`);`temp=null` = 该位置本刻**无读数**(不是 0℃)
- `probes[].id` = 探头 ROM 短码(未识别为 `null`);`alert_mask`:bit j = 槽位 j 越限、bit7 = 位置内温差超限
- 服务端**不展开**等间隔序列(温变上报 10~120s 一条,展开纯浪费带宽)⇒ 平滑曲线由客户端前向填充
**② `sensorAvg` → `points[]`:按 `step` 分桶(画趋势用这个)**
```json
{ "ts": 1789700400, "avg": 26.54, "min": 26.5, "max": 26.56, "samples": 86, "coverage_s": 1800 }
```
- 温度读 **`avg`**(时间加权 `SUM(t×秒)/SUM(秒)`,温变期比简单 AVG 准)—— **没有 `temp` 键**
- `ts` = 桶起点(UTC 对齐,App 按本地时区格式化);`coverage_s < step` = 桶内有断档,可画「数据缺口」
- 「昨天 11:00 → 现在,每 30 分钟一点」:`step=1800&from=1789614000&limit=2000`(不传 `from` 时用 `window=N` 分钟)
- **只回有数据的桶**,空档不补点(服务端不铺骨架、不补 0);想要连续骨架由客户端按 `step` 自己铺
**③ `recentAvg` → `positions[]`:每位置一条汇总值(首屏/排障用这个)**
```json
{ "index": 1, "alias": "位置B", "avg": 39.6, "min": 31.81, "max": 46.19,
"samples": 57, "coverage_s": 3480, "last_ts": 1789699572, "data_ok": true }
```
- 无样本时 `avg=null` 且 `data_ok=false`(前端显示「无数据」,**不要显示 0℃**)
**排障:`debug=1`(`sensorSeries` / `sensorAvg` 都支持)**
附加 `data.debug`:`database / table / sql / server_ts / uuid / pos / from / to / rows_uuid / rows_pos / rows_range / min_ts / max_ts / pos_min_ts / pos_max_ts / recent[3] / error` —— 用于定位「直接跑 SQL 有数据、接口却空 / 全 0」:
- `database`、`table` 与 SQL 客户端不一致 ⇒ 接口连错库 / 表前缀不对
- `rows_uuid=0` ⇒ 这个库里没有这台设备(你查的是另一个库/另一个站点)
- `rows_uuid>0` 但 `rows_range=0` ⇒ 时间条件不成立或时钟偏移(比 `server_ts` 与 DB 的 `UNIX_TIMESTAMP()`,并确认 `ts` 单位是秒不是毫秒)
- `error` 非空 ⇒ 原来被 `catch` 静默吞掉的异常原文(同时落 `runtime/log`:`[iot] sensorSeries/sensorAvg 查询失败`)
### 2.5 日志与告警
| 方法 | 路径 | 参数 |
|---|---|---|
| GET | `deviceLogs` | `uuid`、`page=1`、`size=20`(上限 100)、`direction=in\|out`(空 = 全部) |
| GET | `alertList` | `page`、`size`(上限 100)、`unread=0\|1` |
| POST | `readAlert` | `ids[]`(**不传或空数组 = 全部标已读**),返回 `{affected}` |
| GET | `alertUnread` | — → `{unread}` |
### 2.6 场景联动
| 方法 | 路径 | 参数 |
|---|---|---|
| GET | `sceneList` | — |
| GET | `sceneDetail` | `id` |
| POST | `saveScene` | `id?`、`title`、`icon`、`enabled`、`triggers`(JSON 串)、`actions`(JSON 串) |
| POST | `deleteScene` | `id` |
| POST | `toggleScene` | `id`、`enabled`(0/1) |
| POST | `runScene` | `id`(手动触发) |
| GET | `sceneLogs` | `id`、`page`、`size` |
```jsonc
// triggers[]
{ "type": "device_state|schedule|manual",
"topic": "/devices/+/state",
"condition_json": [{"field": "positions.0.temp", "op": "gt", "value": 60, "ref": "..."}],
"cron": "0 8 * * *",
"enabled": 1 }
// actions[]
{ "type": "send_cmd|http|notify",
"device_uuid": "...",
"payload_json": "...",
"topic": "..." }
```
### 2.7 设备分组
| 方法 | 路径 | 参数 |
|---|---|---|
| GET | `groupList` | — (含设备数 / 在线数) |
| POST | `saveGroup` | `id?`、`title`、`type`(room/custom)、`sort?` |
| POST | `deleteGroup` | `id` |
| POST | `groupAddDevice` | `group_id`、`device_id` |
| POST | `groupRemoveDevice` | `group_id`、`device_id` |
| GET | `groupDevices` | `group_id` |
### 2.8 OTA
| 方法 | 路径 | 参数 | 说明 |
|---|---|---|---|
| GET | `otaList` | `uuid?`(不传 = 全部设备) | 返回 `{list:[{device, ota\|null}]}`,按 `model` 取最新启用固件 |
| POST | `publishOta` | `ota_id`、`uuid` | 需 `PERM_CTRL` |
---
## 三、设备侧 / 运维 API(公开)
| 方法 | 路径 | 参数 | 说明 |
|---|---|---|---|
| POST | `/iot/api/deviceActivate` | `sn`、`token` | 入云激活(免登录),幂等:已激活不再下发 secret |
| GET | `/iot/api/status` | — | `{online, total, messages, topics}` |
| POST | `/iot/api/publish` | `topic`、`payload`、`qos` | **免登录、无命名空间校验**,直接进出站队列 |
> ⚠️ **安全提醒**:路由 `api/publish` 指向的是 `Mqtt::publish`(`noNeedLogin=['*']`),它**没有** `topic` 前缀校验;
> 而带 `{account}/` 命名空间隔离的那版 `Client::publish` **未被路由绑定**。
> ⇒ 该接口目前任何人可往任意主题发消息。上线前建议改为只走 `client/publish` 或补鉴权。
---
## 四、MQTT 连接参数
| 项 | 值 |
|---|---|
| TCP | `mqtt://{host}:1883`(MQTT 3.1.1 / 5.0) |
| WebSocket | `ws://{host}:8083`(**无子路径、二进制帧**);iOS 线上必须 `wss://` |
| 端口来源 | 后台「服务设置」`/iot/backend/setting` 的 `tcp_port` / `ws_port` |
| 设备 clientId / username | 均为 `uuid`;password = `secret` 明文(库中存 bcrypt) |
| 注册阶段 clientId | 建议直接用 `mac`(未激活无凭证) |
| App clientId | 建议 `app-{account}-{随机串}`,避免与设备 `uuid` 撞名 |
### 认证三选一
| 方式 | username | password | 前提 |
|---|---|---|---|
| 认证账号 | 后台「认证」表账号 | 对应密码 | `iot_auth` 启用 |
| 会员账号 + 登录密码 | `member_user.account` | 会员登录密码(bcrypt 校验) | 后台开 `allow_member_login` |
| **一次性 ticket(推荐)** | `account`(`mqttTicket` 返回) | `ticket` | 每次连接 / 重连前重新换,TTL 60s |
- Clean Session 建议开启;重连不依赖持久会话,靠 retained 兜底
- 订阅被拒时服务端**仍回 SUBACK**,结果码 `0x80`(3.1.1)/ `0x87`(5.0)→ App 必须处理 `onSubAck`
---
## 五、设备私有主题 `/devices/{uuid}/…`
> 📄 **主题速查(功能 / 参数 / 谁发谁订 / 权限矩阵 / 排错)见 `iot_topics.md`** —— 本节只保留报文版式与历史沿革细节。
| 主题 | 方向 | QoS | retain | payload |
|---|---|---|---|---|
| `state` | 设备 → 服务端 | 0 | **1**(影子) | 见 5.1 |
| `status` | 设备 → 服务端 | 0 | **1** | 上线 `{"online":1,"pins":[3,4,10]}`(v1.7.0+ 顺带报一次总线 GPIO 表);LWT 固定 `{"online":0}` |
| `config` | 服务端 → 设备 | 1 | **1** | 见 5.2 |
| `ack` | 设备 → 服务端 | 0 | 0 | `config_ack` 回执,见 5.3 |
| `cmd` | 服务端 / App → 设备 | 请求决定(默认 0) | 0 | `{"action":"..."}`,见 5.4 |
| `ota` | 服务端 → 设备 | 1 | 0 | 见 5.5 |
| `ota/report` | 设备 → 服务端 | 0 | 0 | 见 5.5 |
| `event` | 设备 → 服务端 | 0 | **1** | 见 5.6 |
### 5.1 `state`(retained 影子)
固件 **v1.7.0** 起键名压到 2 字母,并去掉两处冗余(顶层 `uuid`、每位置 `pin`);
结构沿用 **v1.6.0** 的**紧凑版式**
(全长报文 ≈533B → ≈370B(v1.6.0)→ ≈349B(v1.7.0 键名)→ ≈300B(v1.7.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
] }
```
> **两处冗余为什么能去掉**
> ① `uuid`:报文本来就发布在 `/devices/{uuid}/state` 上,「谁发的」主题里已经写着;
> 服务端 `SensorPointService::capture()` 本来就从主题正则取 uuid,App 订阅时也早知道 ⇒ 省 ≈25B。
> ② `pin`:它是**编译期常量** `ONEWIRE_PINS[下标]`,设备这辈子都不会变。改成设备**上线时**
> 在 `status` 报文里报一次(见上表),服务端缓存进 `iot_device.pins`、随 `deviceConfig`
> 下发 ⇒ 人眼对接线端子的信息一点没少,每启用位置省 ≈8B。
> ⚠️ **已烧录的 ≤1.6.2 固件仍会带这两个键**,服务端/App 一律「有则用、无则退」,
> 所以**不需要重烧、也不需要灰度窗口**。
> **键名沿革**(服务端 / App 一律「新键优先、旧键兜底」,**老固件不用重烧**):
> `iv` ← `interval`(≤1.5.0 也曾叫 `iv`)、`pos` ← `positions`、`pr` ← `probes`、`cv` ← `cfg_ver`。
> 只改键名,结构与语义一字未动。
| 字段 | 含义 |
|---|---|
| `pos`(旧名 `positions`) | ⚠️ **数组下标就是位置序号**(0 起,= OneWire 总线 A/B/C),**已没有 `index` 字段**;`null` = 该位置未启用(尾部占位会截掉,**中间的不截** —— 截了下标就前移) |
| ~~`pin`~~ | ⚠️ **v1.7.0 起 state 不再带此键**(改由设备上线时在 `status` 报一次 → 服务端缓存 → 经 `deviceConfig` 的 `pins` 下发)。它本来就是**纯属性、不是身份**:换板/改脚时它变、位置序号不变,所以别名/阈值/历史/告警都还能对上。≤1.6.2 老固件仍会带,解析照旧 |
| `t` | 该位置温度(有效探头均值);`null` = 无有效读数(`null` 显示 `--`,别显示 0) |
| `df` | 位置内极差(max − min);`null` 同 `t` |
| `pr[]`(旧名 `probes`) | 该总线各探头;**下标 == 总线槽位**,尾部空槽已截(空总线 `[]`)。元素是**二元组 `[身份, 温度]`** |
| `pr[][0]` | 探头身份(ROM 短码);`null` = 未识别。**ROM 枚举到就上报,与读数成败无关** |
| `pr[][1]` | 该槽温度;`null` = 无读数。配身份可区分「没插」与「插了读不到」 |
| `ts` | NTP Unix 秒,`0` = 未对时 |
| `iv`(旧名 `interval`) | 本条覆盖秒数(前向填充有效期 `[ts-iv, ts]`)。≤1.5.0 的固件也叫 `iv` |
| `cv`(旧名 `cfg_ver`) | 设备当前生效配置版本;与本地不一致就重拉 `deviceConfig`(HTTP 字段名仍叫 `cfg_ver`) |
> **压字节的账(v1.7.0 已做全)**:`pos`/`pr` 的字节数随「位置数 × 探头数」线性增长,是重点;
> `iv`/`cv` 每条只出现一次、收益最小,但既然这份报文的定位就是「压到最小」,索性一次压到底:
> ① 键名 `interval/positions/probes/cfg_ver` → `iv/pos/pr/cv`;② 去掉顶层 `uuid`;
> ③ 去掉每位置的 `pin`(改为上线时在 `status` 报一次)。
> 累计 549B → 300B(3 位置 2 探头),**其中高频部分压掉约 45%**。
> **探头身份(`probes[][0]`)怎么来的**(v1.6.1):DS18B20 的 64bit ROM = `[0]` 家族码(0x28)
> + `[1..6]` 48bit 序列号 + `[7]` CRC8,身份无信息量的两端不参与。短码取**序列号末 2 字节**
> (`addr[5..6]`)→ 4 位十六进制;若末 2 字节为 `0000`(克隆/翻新片常见:序列号高位恒 0),
> 依次退让到中 2 字节、前 2 字节,取第一个非 0 的 —— 这类片才有身份。
> **只有 6 字节序列号全 0** 才发 `null`:那种片各片 ROM 完全相同,任何切片都分不开,
> 报文上只能按槽位区分(固件串口会明确提示)。
> 🚨 **身份是 `null` 但温度有值(`[null, 24]`)= 取不到 ROM 短码,不是读数失败**:
> `[null, null]` 才是「该槽没插器件」。三种成因按概率排:
> ① 固件 **< v1.6.1** 且上电瞬间 `getAddress()` 失败过一次 —— 旧代码照样把计数记成枚举数,
> 「数量变了才重读 ROM」永久为假 ⇒ 身份永久 `null`(**温度正常、重启也不恢复**,v1.6.1 已修);
> ② 该片序列号 6 字节全 0(无唯一身份,只能按槽位区分);③ 该槽真的没插(此时温度也 `null`)。
> **更早的三个版式**(服务端与 App 一律按「键在不在」判版式,**不比较版本号字符串**):
> - **v1.6.0**:`positions[].{pin,t,df,probes[[id,temp]]}` —— 与 v1.7.0 完全同构,只是键名写全。
> - **v1.5.0**:`positions[].{index,pin,temp,temp_diff,probes[{id,temp}]}` —— 键名写全、按 `index` 对齐。
> 与 v1.6.0+ 靠**元素里有没有 `index`** 区分(v1.5 每个位置都写,v1.6 起彻底删掉)。
> - **≤1.4.0**:`data[].{i,t,df,v,vs[],ok[],ids[]}` + 顶层 `iv`。
> ⚠️ 旧版的 `t` 与 v1.6.0+ 的 `t` **同键名但语义不同**:旧版靠 `v` 标志判有效(`v=0` 时 `t` 照样带
> `0`),新式靠 `null` 判 —— 解析必须**先判有没有 `v`**,否则会把「没读数」存成 `0.00℃`。
> - 解析实现:服务端 `SensorPointService::capture()`;App `normalizePositions()` / `normalizeProbes()`;
> 场景条件路径 `SceneService::upgradeStatePath()` —— 它把 `data.` / `positions.` / `probes.` /
> `interval.` 这些历史词汇自动收敛到 `pos.` / `pr.` / `iv.`。⚠️ **改报文键名时必须同步这里**,
> 否则库里已存的场景条件会「取不到值 → 恒不成立」,且**不报错**(静默失效)。
**上报节奏**:采样 10s;触发上报 = 温变 ≥ 0.3℃ / 有效性掩码变化 / 告警翻转 / 距上次 ≥ 120s(另加开机、重连、收到新配置)。
### 5.2 `config`(服务端下发,retained,QoS1)
```json
{ "cv": 7,
"pos": [ {"index":0, "alias":"位置A", "pn":3, "upper":85, "lower":0, "gap":2} ] }
```
全量覆盖语义:`pos` 里没有的位置一律停用。设备写入 NVS 后回 ack。
`pos[]` **只含启用位置**(服务端 `get(uuid, true)`),所以元素里的 `index` 是必需的(见下)。
⚠️ 旧固件(≤1.7.1)收到本格式只回 ack `missing pos`:配置不生效,但**不会误报成功**,一眼可见。
> 🔑 **上下行同键名**(v1.7.2 起):下行 `cv` / `pos` / `pn` 与 §5.1 的 state 完全一致,抄报文不用换脑子。
> v1.7.0 曾为「已烧录的老固件不重烧也能收配置」叠了三层兼容:下行写长名 `positions`/`probes`、
> 复制一份旧式 `data[]`、标量新旧同发(`cfg_ver`/`probes` 与 `cv`/`pn` 同值双发)——
> 2026-09-15 **全部删除**。理由:现场只有两台自用设备、都会重烧固件,兼容收益为 0,而它
> 每多一个「同义键」就多一把备用钥匙:服务端读 `index`、固件读 `i`,改键名时只改一边就
> **静默拆锁**(当天现场事故:位置序号全变 0、数据全填位置 0、编辑一行改掉所有行)。
> 旧的「宁可多发 6B 标量也不复制数组」是为了绕开已烧录设备的 1024B ArduinoJson 解析池;
> 现在没有老设备,这个约束也就没有必要了。
> · ⚠️ `probes` 在**上行是探头明细数组、下行原是探头数(数字)** —— 下行改名 `pn` 后两义消失。
> 🔑 **下行必须有 `index`,上行反而没有** —— 这是唯一一处上下行"不对称",**不是漏改**:
> · 上行 state 的 `pos[]` 靠**数组下标**定位序号:未启用位置写 `null` 占位把下标撑住,
> 每个位置省 10B,而它是每 10s~120s 一条的高频报文,字节值钱;
> · 下行 `pos[]` **只含启用位置** ⇒ 下标会塌缩(位置 0 停用时,位置 2 排在报文下标 0 上)
> ⇒ 元素里必须带 `index`,固件也**不能用下标顶替**(见固件 `onConfigJson` 注释)。
> 🔴 升级顺序:**先烧固件 ≥1.7.2,再上线本改动。**
### 5.3 `ack`(设备 → 服务端,非 retained)
```json
{ "type":"config_ack", "success":true, "message":"...", "cv":7,
"mac":"a0b1c2d3e4f5", "uuid":"A1B2C3D4E5F6" }
```
`cv` 是唯一的版本键(v1.7.2 起;≤1.6.2 老固件回 `cfg_ver`,服务端已不再兜底)。
服务端 `SensorConfigService::onAck()` 据此记 `sync_ver`。
### 5.4 `cmd`(下发命令)
```json
{"action": "report"} // 立刻上报一次 state
{"action": "reboot"} // 重启(保留凭证)
{"action": "wifi_reset"} // 只清 WiFi 凭据 → 重启进配网页;凭证/配置保留
{"action": "clear_identity"} // 清 uuid/secret/配置 → 重启自动重注册;WiFi 保留(服务器写死在固件)
{"action": "factory_reset"} // 全清(含 WiFi)→ 进配网页
```
- 三个清除类动作设备会先回 `ack` 再重启(`delay(500)` 等 ack 出网)
- 未知 `action` 只打日志 `[CMD] unknown action=...`,不报错
- 前提:调用方对 `cmd` 有**发布权限**(主人 或 `perm=2` 共享者)
### 5.5 `ota` / `ota/report`
```json
// 下行 /devices/{uuid}/ota(固件实际只用 url + version,hash/size 被忽略)
{"version":"1.2.4", "url":"https://.../fw.bin", "hash":"sha256(64hex)", "size":123456}
// 上行 /devices/{uuid}/ota/report
{"state":"downloading|installing|done|fail", "progress":42, "version":"1.2.4", "error":"http 404"}
```
`done` → 写 level 1 告警;`fail` → level 3 告警;其它 → `push` 进度通知(不写告警表)。
### 5.6 `event`(retained 告警)
固件 **v1.6.0** 起为结构化告警(**v1.7.0** 起键名 `probes` → `pr`):
```json
{ "type": "alert",
"alerts": [
{ "alias": "位置A", "t": 92.5, "df": 0.3, "rs": "ov",
"pr": [ [0, "A3F2", 92.5] ] }, // 旧名 probes(≤1.6.2),服务端两版都认
null
] }
```
- `alerts` **数组下标 = 位置序号**(未告警的位置写 `null` 占位,尾部占位截掉)
- `rs` 是原因**短码**:`ov` = 探头越限 / `df` = 位置内温差超限。服务端
`AlertService::REASON_TEXT` 统一展开成中文 —— **改文案不用重烧固件**。
(v1.5.0 发的是中文字符串 `reason`,一条约 24B;≤1.4.0 发 `overunder` / `gap`)
- `alias` **保留写全**:它是「当时的叫法」的快照,告警文案要能独立成立,不依赖事后查配置
- `probes[]` 只列异常探头(稀疏),故每条是三元组 `[槽位, 身份, 温度]` —— 槽位必须显式带,不能靠下标推
- 旧版对应字段:`bad[]`(槽位下标)+ `bad_ids[]`(探头身份,与 `bad[]` 一一对应);
且 ≤1.4.0 的 `alerts` 里根本没有位置号 → 服务端只能记 `POS_NA`(255)
- `AlertService::onDeviceEvent()` 三版都认,逐位置落 `iot_alert`(含 `pos_index`)
采样后判定:新出现告警立即发,持续告警按 `ALERT_REPEAT_MS` 复报。
---
## 六、注册主题与通知主题
### 6.1 注册(MAC 即插即用)
| 主题 | 方向 | payload |
|---|---|---|
| `system/device/register/{mac}` | 设备 → 服务端 | `{"mac":"a0b1c2d3e4f5","type":"esp32c3_temp","model":"KF-C3-3KANG","ver":"1.0"}` |
| `system/device/register/{mac}/resp` | 服务端 → 设备 | `{"code":0,"message":"ok","data":{...}}` |
- **必须先订阅 resp 再 publish 注册**,否则错过回执(回执 QoS1、**非 retained**)
- mac 必须 12 位小写 hex(去冒号),且与主题段一致
- 首次注册回 `secret` 明文,之后幂等更新 `type/model/ver` 不再给 secret
- ACL:这两个主题**恒定放行**,不依赖 ACL 开关,可匿名连接完成注册
- 旧协议兼容:`system/device/register/{sn}` + payload `{"token":"..."}` 或裸 token
### 6.2 通知 `/notify/{account}/{子主题}`(QoS1,retain=1)
| 子主题 | 触发 | payload 摘要 |
|---|---|---|
| `alert` | 设备离线 / OTA 完成失败 | `{"id","type":"alert","level":1..3,"title","content","uuid","time"}` |
| `event` | 设备 `/event` 上报 | 同上,`type=event` |
| `scene` | 场景 `notify` 动作 | `{"id","type":"scene","level":2,"title","content","uuid","time"}`(`SceneService` → `AlertService::add($memberId,'scene',2,title,content)`;原先此处写的 `scene_id`/`message` 字段代码里并不存在,2026-09-15 校正) |
| `ota` | 升级进度 | `{"type":"progress","uuid","state","progress","version","time"}` |
| `ota` | 升级完成 / 失败 | `{"type":"alert","uuid","level","title","content","time"}` |
`time` 均为 Unix 秒。App 统一订阅 `/notify/{account}/#` 即可。
---
## 七、服务端消息流(Broker 钩子)
```text
设备 publish /devices/{uuid}/{state,event,ota/report,ack} 或 system/device/register/{mac}
→ Broker(QoS0/1 立即分发;QoS2 在 PUBREL)
├─ DeviceLogService::log 入库留痕(仅 /devices/*)
├─ SceneService::onMessagePublished 场景条件评估 → 动作入 outbox
├─ OtaService::onReport ota/report → 告警 / 进度通知
├─ AlertService::onDeviceEvent event → 告警 + notify
├─ SensorConfigService::onAck ack → 记 sync_ver
└─ handleDeviceRegister 注册 → 回执 dist 到 resp
→ outbox 消费器(0.5s)发布 cmd / notify
```
ACL 关键规则(`Auth::checkAcl` 判定顺序):
1. `$SYS` 只读
2. 注册主题放行
3. 超管放行
4. **私有主题硬归属**(`/devices/{uuid}`、`/notify/{account}`)
5. `acl_enabled` 开关
6. 首条命中规则
7. `acl_default` 兜底
私有主题只看 `iot_device.member_id` + `iot_share`:设备本体放行、主人全权、`perm=2` 全权、`perm=1` 仅可订阅 `state|status|event|config|ack|ota/report` 且**不能发 `cmd`**;`/devices/+/state` 这类跨设备通配符默认拒绝。
---
## 八、后台接口(`/iot/backend/*`,Bearer JWT)
| 分类 | 接口 |
|---|---|
| 概览 | `GET index`、`statsData` |
| 连接 / 消息 | `GET connections`、`connectionsTable`、`messages`、`messagesTable` |
| 发布 / 设置 | `GET|POST publish`、`GET|POST setting` |
| 认证 | `GET auth`、`authTable`、`POST saveAuth`、`deleteAuth` |
| ACL | `GET acl`、`aclTable`、`POST saveAcl`、`deleteAcl` |
| 设备 | `GET device`、`deviceTable`、`deviceLogs`、`deviceConfig`;`POST saveDevice`、`resetDeviceSecret`、`resetActivateToken`、`deleteDevice`、`saveDeviceConfig` |
| 规则 | `GET rule`、`ruleTable`;`POST saveRule`、`deleteRule` |
| 场景 | `GET scene`、`sceneDetail`、`sceneLogs`;`POST saveScene`、`deleteScene`、`toggleScene`、`runScene` |
| 分组 | `GET group`;`POST saveGroup`、`deleteGroup` |
| 告警 | `GET alertsPage`、`alerts`;`POST readAlertAll` |
| OTA | `GET ota`、`otaList`、`otaTable`;`POST saveOta`、`deleteOta`、`toggleOta`、`publishOta` |
> ⚠️ broker 配置**只在启动时读一次** → 后台改完「服务设置」必须重启 broker。
> ⚠️ 后台「删除设备」是**硬删**(清 `iot_device`/`iot_auth`/`iot_acl`/`iot_retain`)+ 设备端 NVS 仍留旧 uuid → 设备永久变砖,见 `device_register_mqtt.md` 的 BOOT 键重置章节。
---
## 九、已发现的不一致 / 待修正
1. `device_register_mqtt.md` 称「插件 API 的 `Result::success` 用 `code=1` 表示成功」——**错误**,`Result::success()` 内 `code` 硬编码为 `0`。
2. 同文档「用户绑定设备识别码」返回示例写 `"code": 1` 表示绑定成功——**反了**,实际 `code=0` 成功、`code=1` 失败;且 `Client` 系列字段名是 `msg` 而非 `message`。
3. 文档 / 代码注释里出现的 `/iot/api/myDevices`、`/iot/api/mqttTicket` 等(无 `client` 段)是历史写法;`route/app.php` 实际注册的是 `/iot/api/client/*`。
4. `setCount()` 与 `apiSuccess()` 不配套 → 这些接口的响应里没有 `total`。
5. `/iot/api/publish` 无鉴权、无 topic 校验,属安全隐患(见第三节)。
+317
View File
@@ -0,0 +1,317 @@
# IoT 设备主题速查(功能 / 参数 / 权限)
> 本文只讲**主题**(topic):值不值得用、谁发谁订、payload 每个字段是什么、服务端收到后做什么、有哪些坑。
> REST 接口清单见 `iot_api_reference.md` §二/§三;报文版式沿革见该文 §5.1;固件实现见 `addon/iot/firmware/water_temp/water_temp.ino`。
> 主题常量唯一出处:`addon/iot/service/DeviceService.php`(`TOPIC_*` 与 `deviceTopics()`)、`addon/iot/service/OtaService.php`(OTA 两条)。
---
## 一、命名空间总览
| 命名空间 | 归属 | 保护方式 |
|---|---|---|
| `/devices/{uuid}/…` | 单台设备私有 | **私有主题硬归属**(`iot_device.member_id` + `iot_share`),**不受 `acl_enabled` 开关影响** |
| `/notify/{account}/…` | 单个会员私有 | 同上,仅 account 本人 |
| `system/device/register/{mac}[/resp]` | 注册通道 | **恒定放行**(可匿名连接完成注册) |
| `$SYS/…` | Broker 内置指标 | 只读 |
```text
┌──────────────── 服务端(Broker 钩子 + outbox 消费器)────────────────┐
固件 ──publish──► │ state / status / event / ack / ota/report / register │
│ │ │
│ └─(业务处理)─► iot_device / iot_sensor_point │
│ iot_alert / iot_scene │
固件 ◄─subscribe─ │ config / cmd / ota ◄── outbox ◄── SensorConfigService / App / 场景 │
App ◄─subscribe─ │ state / status / event / ack / ota/report / notify/{account}/# │
└─────────────────────────────────────────────────────────────────────┘
```
---
## 二、设备私有主题 `/devices/{uuid}/…`(8 条)
| 主题 | 方向 | QoS | retain | 发布者 | 订阅者 |
|---|---|---|---|---|---|
| `state` | 上行 | 0 | **1** | 固件 | 服务端、App(含 `perm=1` 只读共享) |
| `status` | 上行 | 0 | **1** | 固件(含 LWT) | 服务端、App |
| `event` | 上行 | 0 | **1** | 固件 | 服务端、App |
| `ack` | 上行 | 0 | 0 | 固件 | 服务端 |
| `ota/report` | 上行 | 0 | 0 | 固件 | 服务端 |
| `config` | 下行 | 1 | **1** | `SensorConfigService` | 固件 |
| `cmd` | 下行 | 调用方指定(默认 0) | 0 | App `sendCommand` / 场景 `send_cmd` / 后台发布 | 固件 |
| `ota` | 下行 | 1 | 0 | `OtaService::publish` | 固件 |
**retain 的分工是刻意的**:四条 **retain=1** 的是「当前状态」(App/固件订阅瞬间就拿到全量现状,首屏不用轮询);四条 **retain=0** 的是「一次性动作/回执」(重连时不该收到过期的命令和旧回执)。
### 2.1 `state` —— 影子(设备现状快照)
功能:设备的**唯一状态真源**。服务端写「影子」(retain 兜底),App 面板/曲线/骨架都由它驱动。
固件 v1.7.0 起(旧名见 §2.9):
```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
] }
```
| 字段 | 参数含义 |
|---|---|
| `ts` | NTP Unix 秒;`0` = 未对时(服务端用接收时间兜底) |
| `ver` | 固件版本字符串。**判定版式不要看它**(只看键在不在) |
| `rssi` | 信号强度 dBm(诊断用) |
| `iv` | 本条覆盖秒数(旧名 `interval`)→ 曲线前向填充有效期 `[ts-iv, ts]` |
| `cv` | 设备当前**生效**的配置版本(旧名 `cfg_ver`)。与本地不一致 → 重拉 `deviceConfig` |
| `pos[]` | **数组下标 = 位置序号**(= 一条 OneWire 总线);`null` = 该位置未启用(占位撑下标) |
| `pos[].t` | 位置温度(有效探头均值);`null` = 无有效读数(**显示 `--`,不是 0**) |
| `pos[].df` | 位置内极差(max − min);`null` 同 `t` |
| `pr[]` | 该总线各探头(`pr` = probes 明细);**下标 = 总线槽位**,尾部空槽已截 |
| `pr[][0]` | 探头身份(ROM 短码,4 位 hex);`null` = 未识别 |
| `pr[][1]` | 该槽温度;`null` = 无读数。`[null,24]` = 有读数无身份;`[null,null]` = 没插器件 |
> ⚠️ **报文里没有位置名**。位置别名只来自 `deviceConfig` 接口(`SensorConfigService::format()`);App 靠下标/元素序号匹配骨架。
> ⚠️ **没有 `index`**:上行靠下标定位,未启用位置写 `null` 占位 —— **下标永不前移**。`pr` 同理,下标恒等于总线槽位,禁止 `filter` 掉 null 重编号。
> ⚠️ **没有 `pin`**(≤1.6.2 老固件仍会带):pin 是编译期常量,改由上线时 `/status` 报一次(见 §2.2)。
> ⚠️ `t` 是**同名陷阱**:≤1.5.0 的 `data[].t` 靠 `v` 标志判有效(`v=0` 时照样带 `0`),≥1.6.0 靠 `null` 判 ⇒ 解析必须**先判有没有 `v`**,否则把「没读数」存成 `0.00℃`。
上报节奏:采样 10s;触发上报 = 温变 ≥ 0.3℃ / 有效性掩码变化 / 告警翻转 / 距上次 ≥ 120s(另加开机、重连、收到新配置)。
服务端收到后:`DeviceLogService::log` → `SensorPointService::capture`(时序落库 `iot_sensor_point` + 云端阈值复核写 `alert_mask`)→ `SceneService::onMessagePublished`(场景条件评估)。
### 2.2 `status` —— 在线态 + 引脚表
| 场景 | payload |
|---|---|
| 上线 | `{"online":1,"pins":[3,4,10]}`(v1.7.0+ 顺带报一次总线 GPIO 表) |
| 掉线(LWT) | `{"online":0}` |
功能三合一:
1. **在线态真源**(App 在线/离线、后台在线数)。与 `event` 分主题就是为了避免 retained 互相覆盖。
2. **引脚表缓存**:`DeviceService::onStatusMessage()` 把 `pins` 写成 `iot_device.pins`(CSV),随 `deviceConfig` 的 `pins` 下发给 App。pin 只是**属性**(换板会变),位置序号才是身份。
3. **自动补位置 + 配置重发**:拿到 pins 后 `fillMissing()` 按下标补齐缺失位置(只补不覆盖、停用不复活、有新增才 `bumpVer`+push),无新增则 `repush()`(同版本重发,治「设备端被打瘫、两侧版本却对齐」)。
> ⚠️ 设备从未上报过(≤1.6.2 或从未上线)时 `pins` 为 `[]`,App 回退用 state 里的 `pin` 自补显示。
### 2.3 `event` —— 告警 / 事件
**两种形态,任选其一**:
```jsonc
// A. 结构化温度告警(alerts 下标 = 位置序号;未告警位置写 null 占位)
{ "type": "alert",
"alerts": [ { "alias": "位置A", "t": 92.5, "df": 0.3, "rs": "ov",
"pr": [ [0, "A3F2", 92.5] ] }, null ] }
// B. 通用事件(任何品类可用,服务端零改动)
{ "title": "风扇已开启", "content": "手动触发 / 定时触发", "level": 2 }
```
| 字段 | 含义 |
|---|---|
| `alerts[].alias` | 告警发生时的**位置叫法快照**(保留写全,让文案能独立成立) |
| `alerts[].t` / `.df` | 触发时的温度 / 极差 |
| `alerts[].rs` | 原因**短码**:`ov` = 探头越限 / `df` = 位置内温差超限(服务端 `AlertService::REASON_TEXT` 展开中文,**改文案不用重烧固件**) |
| `alerts[].pr[]` | 只列异常探头(稀疏)⇒ 每条是**三元组** `[槽位, 身份, 温度]`,槽位必须显式带 |
| 通用分支 | `title` / `content`(缺省取 `msg` 或原文)/ `level`(默认 2) |
服务端:`AlertService::onDeviceEvent()` 逐条落 `iot_alert`(带 `pos_index`)+ 推送 `/notify/{account}/alert`。
≤1.4.0 老版式(`bad[]` / `bad_ids[]`,无位置号 ⇒ 记 `POS_NA`=255)与 ≤1.6.2 的 `probes` 键名服务端仍认。
无告警时不发(`event` 是 retained,所以「无告警」靠不发布,而不是发空数组)。
### 2.4 `ack` —— 回执
```json
{ "type":"config_ack", "success":true, "message":"Saved 3 sensor(s)", "cv":7,
"mac":"a0b1c2d3e4f5", "uuid":"A1B2C3D4E5F6" }
```
| 字段 | 含义 |
|---|---|
| `type` | 恒为 `config_ack`(**没有单独的 `cmd_ack`**,命令执行结果也复用本格式) |
| `success` | 写入结果 |
| `message` | 失败原因(如 `missing pos` —— 旧固件收到新格式版式会这样回,**可见、不静默**) |
| `cv` | 写进 NVS 的配置版本,服务端据此记 `sync_ver` ⇒ `applied = (sync_ver >= cfg_ver)` |
服务端:`SensorConfigService::onAck()`,**只在 `type` 为空或 `config_ack` 时处理**。
> ⚠️ **缺 `type` 的回执会被当成 config_ack**(写 `sync_ver=0, sync_at=now`)。非数值品类的回执务必带自己的 `type`(如 `{"type":"sw_ack"}`),或者干脆不发 `/ack`。
### 2.5 `cmd` —— 命令 / 控制(透明管道)
```json
{"action":"report"} // 立刻上报一次 state
{"action":"reboot"} // 重启(保留凭证/配置)
{"action":"wifi_reset"} // 只清 WiFi 凭据 → 重启进配网页(凭证/配置保留)
{"action":"clear_identity"} // 清 uuid/secret/配置 → 重启自动重注册(WiFi 保留)
{"action":"factory_reset"} // 全清(含 WiFi)→ 配网页
```
| 要点 | 说明 |
|---|---|
| 服务端**不校验 payload** | `Client::sendCommand` 任意 JSON 直接进 outbox;**指令词表由固件定义**(`onCmdJson`),服务端零改动 |
| `action` 之外的字段 | 固件可自行扩展(如开关类 `{"action":"set","sw":[1,0]}`);未知 action 固件只打 `[CMD] unknown action=…`,不报错 |
| QoS | 由调用方指定(`sendCommand` 的 `qos` 参数,默认 0) |
| retain | 恒 0(一次性动作) |
| 权限 | 需对该设备有**发布**权:主人 / `perm=2` 共享者。`perm=1` 连订阅 `cmd` 都被拒 |
| 回执 | 清除类动作先 `ack` 再重启(`delay(500)` 等 ack 出网);`reboot` 无 ack |
### 2.6 `ota` / `ota/report` —— 固件升级
```jsonc
// 下行 /devices/{uuid}/ota(QoS1,retain=0)
{"version":"1.2.4", "url":"https://.../fw.bin", "hash":"sha256(64hex)", "size":123456}
// 固件实际只用 url + version,hash/size 被忽略
// 上行 /devices/{uuid}/ota/report(QoS0,retain=0)
{"state":"downloading|installing|done|fail", "progress":42, "version":"1.2.4", "error":"http 404"}
```
服务端 `OtaService::onReport()`:
| `state` | 处理 |
|---|---|
| `done` | `iot_alert` level 1「固件升级完成」 |
| `fail` | `iot_alert` level 3「固件升级失败」+ 错误原因 |
| 其它 | 只推 `/notify/{account}/ota` 进度通知(`{"type":"progress",…}`),**不写告警表**(避免刷屏) |
---
## 三、注册主题(MAC 即插即用)
| 主题 | 方向 | QoS | retain | payload |
|---|---|---|---|---|
| `system/device/register/{mac}` | 设备 → 服务端 | 0 | 1(固件传 `true`) | `{"mac":"a0b1c2d3e4f5","type":"esp32c3_temp","model":"KF-C3-3KANG","ver":"1.0"}` |
| `system/device/register/{mac}/resp` | 服务端 → 设备 | 1 | 0 | `{"code":0,"message":"ok","data":{uuid,secret,topics,…}}` |
流程与铁律:
1. **必须先订阅 `resp` 再发布注册** —— 回执非 retain,错过即丢(固件 `mqttSubscribeAll()` 里对订阅结果做了检查,订阅失败就直接不发注册)。
2. `mac` 必须 12 位小写 hex(去冒号),且与主题段一致(不一致回 `payload mac 与主题不一致`)。
3. 首次注册回 `secret` 明文,之后幂等更新 `type/model/ver`,**不再给 secret**。
4. ACL 恒定放行,**可匿名连接**完成注册(此时还没有凭证)。
5. 旧协议兼容:`system/device/register/{sn}` + payload `{"token":"..."}` 或裸 token(走 `activate_token` 激活)。
6. 服务端处理:`Broker::handleDeviceRegister()` → `DeviceService::registerByMac()`。
---
## 四、通知主题 `/notify/{account}/{子主题}`(QoS1,retain=1)
| 子主题 | 触发场景 | payload 摘要 |
|---|---|---|
| `alert` | 云端告警(温度越限、设备离线、OTA 完成/失败…) | `{id,type:"alert",level:1..3,title,content,uuid,pos,time}` |
| `event` | 设备 `/event` 上报 | `{id,type:"event",level,title,content,uuid,time}` |
| `scene` | 场景 `notify` 动作 | `{id,type:"scene",level:2,title,content,uuid,time}`(`SceneService` 走 `AlertService::add($memberId,'scene',2,…)`,`title` 取动作的 `topic`、`content` 取 `payload_json`) |
| `ota` | 升级**进度** | `{type:"progress",uuid,state,progress,version,time}` |
- 子主题名就是 `AlertService::add()/push()` 的 `type` 参数值(`alert` / `event` / `scene` / `ota`),payload 里的 `type` 与它同值。
- `time` 均为 Unix 秒。
- retain=1 ⇒ App 冷启动订阅后立刻能收到最近一条(当作「有未读」的兜底提示;真正的未读数是 `alertUnread` 接口)。
- **App 只订阅 `/notify/{account}/#` 一条即可**。
- 该主题仅 account 本人可订(`/notify/{账号}/…`,服务端内部推送不经 ACL)。
---
## 五、权限矩阵(谁能订什么 / 发什么)
`Auth::checkPrivateTopic()` 的裁决(顺序在常规 ACL **之前**,与 `acl_enabled` 开关**无关**):
| 身份 | `/devices/{uuid}/*` 权限 |
|---|---|
| 设备本体(`username=clientId=uuid` 且 `iot_auth` 密码已验) | 全权 |
| 设备主人(`iot_device.member_id`) | 全权(含 `cmd`/`ota` 发布) |
| 共享 `perm=2`(`ShareService::PERM_CTRL`) | 全权 |
| 共享 `perm=1`(`ShareService::PERM_VIEW`) | **仅订阅** `state` `status` `event` `config` `ack` `ota/report`(`Auth::isReadonlyTail()`);**不能订 `cmd`/`ota`、不能发任何东西** |
| 认证账号(`iot_auth`,非会员) | 默认拒绝;仅「ACL 规则显式点名该账号 allow」可通 |
| 匿名 / 未知身份 | 一律拒绝 |
补充规则:
- **跨设备通配符订阅**(`/devices/+/state`、`/devices/#`)默认拒绝 —— 单一归属表达不了,放行等于把全部设备暴露给一个人;要放行必须 ACL 规则**点名该账号**。
- 匹配的 ACL 过滤器必须**以 `/devices/` 或 `/notify/` 开头**,否则直接封堵(防 `#` 越权)。
- 服务端在每次 App 换 MQTT ticket 时自愈 ACL 快照(`mqttTicket` → `resyncMemberAcl` + `resyncMember`):自动生成的 4 条 `auto:member:{uuid}:*`(`state` 订 / `status` 订 / `cmd` 发 / `notify` 订)是**生成即快照**,新增规则不会自动补到老设备上,靠这次 resync 补齐。
- 私钥(`secret`)只走注册回执和后台设备管理,**不进任何主题**。
- ⛔ 订阅被拒时服务端**仍回 SUBACK**,结果码 `0x80`(3.1.1)/ `0x87`(5.0)⇒ App 必须处理 `onSubAck`,否则表现为「连上了但一片空白」。拒因定位:`Auth::lastDenyReason()`。
---
## 六、服务端钩子分发映射(`Broker.php`)
QoS0/QoS1 消息在 `handlePublish()` 末尾触发;QoS2 延迟到 `handlePubRel()`(逻辑两处**必须同步改**)。`$SYS` 前缀不触发任何钩子。
| 触发条件 | 调用 |
|---|---|
| 任意非 `$SYS` 主题 | `DeviceLogService::log($topic,$msg,'in')` |
| 任意主题 | `SensorPointService::capture()`(内部自行判 `pos`,非 state 立即 return) |
| 任意主题 | `DeviceService::onStatusMessage()`(内部判 `status`) |
| 任意主题 | `SceneService::onMessagePublished()`(场景条件评估 → 动作入 outbox) |
| `^/devices/([^/]+)/ota/report$` | `OtaService::onReport()` |
| `^/devices/([^/]+)/event$` | `AlertService::onDeviceEvent()` |
| `^/devices/([^/]+)/ack$` | `SensorConfigService::onAck()` |
| `^system/device/register/([^/]+)$` | `Broker::handleDeviceRegister()` |
下行链路(**不经 Broker 直连**):业务代码 → `Store::enqueueManual(topic, payload, qos, retain, source)` 写 `iot_outbox` → outbox 消费器(0.5s)发布并投递。
| 入队方 | 主题 | qos / retain |
|---|---|---|
| `SensorConfigService::push()` | `config` | 1 / 1 |
| `Client::sendCommand()`、场景 `send_cmd`、后台发布 | `cmd` | 调用方 / 0 |
| `OtaService::publish()` | `ota` | 1 / 0 |
| `AlertService::push()` | `/notify/{account}/{type}` | 1 / 1 |
---
## 七、字段/协议的硬约束(改代码前必读)
1. **上下行的唯一不对称:`pos[]` 的 `index`**
- 上行 `state.pos[]`:**靠数组下标**定位序号(未启用写 `null` 占位,每位置省 ≈10B);
- 下行 `config.pos[]`:**只含启用位置** ⇒ 下标塌缩 ⇒ **元素必须带 `index`**,固件不许用下标顶替。
- 两边不是「漏改」,是高频上行压字节换来的。
2. **`config` 是全量覆盖语义**:`pos` 里没有的位置一律停用。⚠️ 位置**全停用会下发 `pos:[]`**,固件对空数组 = 清空 NVS ⇒ 后台至少留一个启用(`push()` 对「真源为空」也会拒发)。
3. **改名要同步的清单**(少一处就静默失效):
- 报文键名 → `SensorPointService::capture()`、`SceneService::dig()`/`upgradeStatePath()`、App `normalizePositions()`/`normalizeProbes()`、后台 `device.html`;
- 位置序号键 `index` → `SensorConfigService::normalize()`、App `device-panel`、后台 `device.html`。
4. **协议升级顺序:先烧固件 ≥1.7.2,再上线服务端**。旧固件收新格式只回 ack `missing pos`(配置不生效但**不误报成功**,一眼可见)。
5. **同一语义只留一个键名**:历史上「`index` + `i` 双发」=一个字段挂两把钥匙,改一边就静默拆锁(2026-09-14 现场事故:ack 成功、位置全没启用)。兼容层已于 2026-09-15 全删。
6. 服务端/App 对**上行**一律「新键优先、旧键兜底」(`pos??positions??data`、`iv??interval`、`cv??cfg_ver`、`pr??probes`)—— **老固件不用重烧**。
---
## 八、常见排查
| 现象 | 先看这里 |
|---|---|
| App「连上了但没有任何数据」 | `onSubAck` 是否处理 `0x80`;`Auth::lastDenyReason()`;订 `/notify/{自己账号}/test` 能成功而设备主题失败 = 降级成 `anonymous-named`(填了用户名但三种验证全没过,member_id=0) |
| 设备在线但 `applied=false` | `ack` 是否收到(`sync_ver` 是否更新);`cv` 是否回对;retained `config` 是否真的发出去(outbox 卡住) |
| 位置配置「老是自动变化」/ 影子全填位置 0 | `iot_sensor_cfg.update_at` 集中在上线时刻 = `fillMissing()` 正常行为;集中在用户操作时刻 = 保存路径读错键回写 |
| 设备上报了但时序库没数据 | `capture()` 首行 `if (!is_array($rawPos) || !$rawPos) return;` —— 报文里没有 `pos`/`positions` 会被**静默跳过**(非数值品类属正常) |
| 告警没进 `iot_alert` | `/event` 是否真的发(retain=1 的旧报文会被 Broker 缓存,注意别被历史 retained 误导);通用分支需要 `title`+`level` |
| 重启后设备不再上报 | 硬删设备后设备端 NVS 仍留旧凭据 ⇒ CONNACK 4/5;救:BOOT 3s 重置 / 向 `…/resp` 发 `{"code":0,data:{uuid,secret}}` / 重烧勾 Erase |
| 想给新品类加控制 | **不用改服务端**:`/cmd` 是透明管道,指令词表由固件定义;状态走 `/state`,事件走 `/event`(通用分支) |
---
## 九、待修 / 已知风险
1. 🔴 `/iot/api/publish`(`Mqtt::publish`,`noNeedLogin=['*']`)**无鉴权、无 topic 前缀校验** ⇒ 任何人可往任意主题发消息。带命名空间隔离的 `Client::publish` 未被路由绑定。上线前建议改路由或补鉴权。
2. `cmd` 与 `config` 的回执复用同一 `type=config_ack`、同一 `/ack` 主题 ⇒ 无法区分「命令执行结果」与「配置写入结果」(当前靠 `message` 文本区别)。
3. `Mqtt::publish` 失败时 `error()` 之后没有 `return`,会继续执行到 `success()`(响应内容自相矛盾)。
4. 固件头注释把上行 `state/status/event` 标为「QoS1」,实际 `PubSubClient::publish(topic,payload,retained)` 走 **QoS0**(第三个参数是 retain,不是 QoS);`subscribe` 才是 QoS1。文档以**实际行为**为准。
---
## 十、变更记录
| 版本 | 变化 |
|---|---|
| v1.7.2(下行) | 下行键名与上行统一(`cv`/`pos`/`pn`);删除全部下行兼容层(`positions`/`data[]`/`cfg_ver`/`index`+`i` 双发);升级顺序要求「先烧固件」 |
| v1.7.0(上行) | 短键 `iv`/`cv`/`pos`/`pr`;去掉顶层 `uuid` 与每位置 `pin`(`pin` 改由 `/status` 上线报一次);约 549B → 300B |
| v1.6.1 | 探头身份 ROM 短码(`pr[][0]`) |
| v1.6.0 | `positions[]` 结构化 + `event` 结构化告警(`alerts[]`/`rs` 短码) |
| v1.5.0 | `positions[].index` 对齐(v1.6.0 起删除该键,改靠下标) |
| ≤1.4.0 | `data[]` + 顶层 `interval`;有效性靠 `v` 标志 |
> 更细的报文演进与压字节账目见 `iot_api_reference.md` §5.1。
+26 -5
View File
@@ -16,6 +16,11 @@ YwxApp 的数据库结构自愈引擎收敛在 `ywxapp/model/BaseModel.php` 的
所有方法内部都包了 `try/catch`:自愈失败(如数据库账号无 `CREATE/ALTER` 权限、`NO_BACKSLASH_ESCAPES` 导致 SQL 出错)会**被静默吞掉并记录日志**,不会阻断业务——但意味着「自愈没起作用」时不会立刻报错,需要查日志才能发现。
> **模块自带 SQL 的情况**:`ensureTableFromInstall()` 只认核心的 `public/install/install.sql`。
> 中心站专用模块 `appmall` 有自己的一份事实源 `app/appmall/install.sql`,由
> `app/appmall/library/AppmallSchema` 用同样的「解析 SQL → `ensureTable()`」方式执行,
> 模块内不再内联重复 DDL。
## 二、模型壳约定
每个需要自愈的模型都自带一个静态方法:
@@ -38,6 +43,21 @@ public static function ensureSchema(): void
**幂等性**:所有自愈都先探测(SHOW TABLES / SHOW COLUMNS)再操作,已存在则跳过,重复调用安全。
**自动触发(appmall 采用)**:`app/appmall/model/` 下的 **13 个模型与本模块 13 张表一一对应**
(清单登记在 `AppmallSchema::MODELS`,加表必查),每个都在 `initialize()` 里调用
`self::ensureSchema()` —— 模型一被使用(含 `Model::where()` 这类静态查询)就先兜底建表 + 补老库扩展列,
业务入口无需记得手动调用,不会再撞 1146(缺表)/ 1054(缺列)。
`AppmallSchema::ensureTable()` 内部按「表」和「表+列」做了进程内 memo,每请求每表最多一次 `SHOW TABLES`,
开销可忽略。(核心库同样有先例:`MemberProp::initialize()` / `CommonCard::initialize()`。)
**应用级中间件兜底(appmall 采用)**:`app/appmall/middleware.php` 注册
`AppmallSchemaMiddleware`,任何进入 `/appmall/*` 的请求(前台页 / 后台页 / 对外 API / 未注册路由)
在业务之前先 `AppmallSchema::ensure()` 一次(见第三节第 3 小节)。
> 为什么要「模型 + 中间件」双层:模型兜底保证**直接使用模型**的代码路径不缺表;
> 中间件兜底保证**任何入口**(含控制器里大量 `Db::name('appmall_xxx')` 直查、
> 以及将来新增的页面 / 接口)不会因为某处忘了调用而缺表。两者都幂等,重复执行无副作用。
## 三、触发时机(谁在什么时候调用 ensureSchema)
自愈**不会定时 / 不会自动**,全部是「业务请求时按需触发」。
@@ -56,11 +76,12 @@ public static function ensureSchema(): void
| `ywxapp/model/Notice.php` | `getActiveTopNotices()` / `getList()` | 前台公告条渲染、公告列表页 |
| `ywxapp/model/Ad.php` | `getByPosition()` / `getSlots()` | 广告位渲染 |
### 3. 中间件 / 蜘蛛统计
### 3. 中间件
| 入口 | 调用 | 触发场景 |
| --- | --- | --- |
| `SpiderStat` 中间件 | `ensureTables()` | 每次请求(蜘蛛日志 / 统计表) |
| `ywxapp/middleware/SpiderStat`(全局) | `ensureTables()` | 每次请求(蜘蛛日志 / 统计表) |
| `app/appmall/middleware/AppmallSchemaMiddleware`(应用级) | `AppmallSchema::ensure()` | **任意** `/appmall/*` 请求(前台 / 后台 / 对外 API) |
### 4. 插件安装 / 升级
@@ -68,9 +89,9 @@ public static function ensureSchema(): void
| --- | --- | --- |
| blog | `BlogSchema::ensure*` | 插件业务方法 |
| forum | `ForumSchema::ensure*` | 插件业务方法 |
| appmall(中心站) | `AppmallSchema::ensure*` | 开发者中心业务方法 |
| appmall(中心站) | 应用中间件 `AppmallSchema::ensure()` + 各模型 `ensureSchema()`(`initialize()` 自动触发) | 任意 `/appmall/*` 请求,或直接使用任一 appmall 模型 |
| haonav / seo | `Addon.php::upgrade()` / `ensureTablesFromInstallSql()` | 插件升级 / 安装 |
| wxchat / mqttbroker / mqpay 等 | 各自 Schema 类 | 插件初始化 |
| wxchat / iot / mqpay 等 | 各自 Schema 类 | 插件初始化 |
## 四、线上老库迁移不在自愈范围
@@ -85,7 +106,7 @@ public static function ensureSchema(): void
1. 确认表是否在 `install.sql` 中有建表语句(事实源)。
2. 若是核心 / 插件表,确认对应 `ensureSchema()` 是否被调用(见第三节入口)。
3. ⚠️ **高频根因:运行时没取到表前缀**。`ensureSchema()` 若用 `Db::getConfig('prefix')` 取前缀,在部分线上环境(多应用路由 / CLI / Db 配置未就绪)会返回空字符串,导致自愈建出**无前缀的表**(如 `notice`),而模型 `name` 拼前缀后查的是 `wxapp_notice` → 1146。
- **修复**:所有模型壳 / 控制器一律改用 `BaseModel::currentPrefix()` 取前缀(优先 `config/database.php`,回退 `Db::getConfig`),并已统一修复 `Notice` / `Ad` 及 `appmall` / `appmall` 控制器。
- **修复**:所有模型壳 / 控制器一律改用 `BaseModel::currentPrefix()` 取前缀(优先 `config/database.php`,回退 `Db::getConfig`),并已统一修复 `Notice` / `Ad`;`appmall` 的控制器 / 服务原先各自内联 DDL 且用 `Db::getConfig('prefix')`,现已全部收敛到 `AppmallSchema`(事实源 `app/appmall/install.sql`,统一走 `currentPrefix()`),并由应用中间件统一触发。
- 自查:`grep "Db::getConfig('prefix')"` 应只剩 0 处(全部换成 `currentPrefix`)。
4. 若前缀正确、`ensureSchema()` 已调用仍 1146 → **多半是线上 DB 账号无 CREATE/ALTER 权限**,自愈被静默吞掉。
- 根治:给 DB 用户授予建表 / 改表权限,自愈会自动补齐;
+33
View File
@@ -0,0 +1,33 @@
# 管理员操作日志(backend_log)
后台的写操作统一留痕,落 `backend_log` 表(`id / aid / action / ip / remark / create_at`)。
## 日志从哪来
| 来源 | 位置 | 说明 |
|------|------|------|
| 通用增删改 | `BackendBase` 的 `create / edit / del / destroy / restore / multi` | 操作**已成功提交**后记录;失败不留痕(避免失败重试刷屏) |
| 登录 / 退出 | `BackendBase::login()` / `logout()` | 登录**成功与失败都记**;失败时 `aid=0`(还不知道是谁) |
| 业务补充 | `AdminLogger::write($action, $remark, $aid)` | 关键业务操作(如删除附件、清理日志)按需补充 |
`action` 约定为 `controller/action`(如 `Admin/create`)或语义动作(`登录成功` / `退出登录`);
`remark` 放可读摘要(如 `ID:12`)。两字段都有长度上限(100 / 255),超长自动截断。
```php
use ywxapp\library\AdminLogger;
AdminLogger::write('order/refund', '订单号:20260101001,金额:39.00');
AdminLogger::login($account, true, '', $aid); // 登录成功
AdminLogger::login($account, false, '密码错误'); // 登录失败
```
日志写入**永不抛异常**(内部吞掉):审计功能不能反过来阻断业务。
## 后台查看
系统管理 → **操作日志**(`app/backend/controller/Adminlog.php`,权限 `adminlog:index|delete|clean`,首次访问自动补录):
- 按账号 / 昵称 / IP / 备注关键字检索,按动作类型下拉筛选,支持日期区间;
- 删除选中;按保留天数清理(填 `0` = 清空,页面有二次确认)。
CLI 清理:`AdminLogger::clean($days)`,同样 `0` = 清空。
+547
View File
@@ -0,0 +1,547 @@
# 智慧生活全流程设计(iot 插件扩展)
> 版本:v0.1(设计稿)
> 日期:2026-08-21
> 目标:以米家/华为智慧生活的核心模型,在现有 `iot` 插件上落地「设备接入 → 云中枢 → App 控制 → 场景自动化 → 告警/OTA」完整闭环。
---
## 一、现状盘点(已有能力)
| 模块 | 现状 | 说明 |
|---|---|---|
| MQTT Broker | ✅ `Broker.php` | 单进程内存态(worker=1),支持 retain / 离线队列 / 遗嘱 / QoS2 |
| 认证 | ✅ `Auth.php` | bcrypt 密码、匿名开关、框架会员直通(`allow_member_login`) |
| ACL | ✅ `Auth.php` | 按 user/client 匹配,`%u`/`%c` 占位符,自动生成设备/会员规则 |
| 影子 | ✅ `Store` + `DeviceService::shadow()` | 设备 `state` 主题 retain=1,离线可查最后状态 |
| 离线/命令 | ✅ `Store::enqueueManual()` | 后台/API 发布经 outbox 出栈,Broker 消费后内部路由,零外部依赖 |
| 设备绑定 | ✅ `DeviceService` | 扫码 SN → 激活 uuid/secret → 绑定会员 → 自动 ACL |
| 事件钩子 | ✅ `Broker` 内 `event('mqtt_message_published')` | 场景引擎的天然接入点 |
| 转发规则 | ⚠️ `RuleEngine.php` | 仅「源主题→外部 Broker」桥接,**无条件判断/动作执行** |
| 场景/自动化 | ❌ 无 | 本次设计核心 |
| 设备分组 | ❌ 无(仅有 `room` 文本字段) | 本次新增 |
| 告警/通知 | ❌ 无 | 本次新增 |
| OTA 升级 | ❌ 无 | 本次新增 |
| 设备日志 | ⚠️ 仅有全量 `message` 表 | 需新增设备维度日志 |
**关键架构事实**:
- Broker 为单进程内存态 → 场景规则可做进程内内存缓存(TTL 刷新,同 `Auth::loadAclRules` 模式)。
- 场景动作不阻塞主循环 → 统一走 `Store::enqueueManual()` 异步投递。
- 所有新增表遵循框架约定:`__PREFIX__iot_*`、时间字段 `create_at/update_at/delete_at`、字段命名禁裸 `name`(用 `title`)。
---
## 二、总体架构
```
┌─ 设备端 ────────────────────────────────────────────────┐
│ 扫码SN → 激活(uuid+secret) → MQTT连云 │
│ publish /devices/{uuid}/state(retain) ← 影子 │
│ publish /devices/{uuid}/event ← 事件/告警条件 │
│ publish /devices/{uuid}/ota/report ← OTA进度 │
│ subscribe /devices/{uuid}/cmd ← 控制命令 │
│ subscribe /devices/{uuid}/ota ← 升级指令 │
└───────────────────────────────────────────────────────────┘
│ MQTT
┌─ 云中枢(iot 插件)───────────────────────────────┐
│ Broker: 认证/ACL/retain/离线/遗嘱/事件钩子 │
│ 影子服务: retain 持久化 + shadow() 查询 │
│ 命令路由: outbox 出站队列 → Broker 消费 → 内部路由 │
│ ★ SceneService: 场景/自动化引擎(新增) │
│ 订阅 mqtt_message_published → 条件匹配 → 动作入 outbox │
│ 定时触发器(Workerman Timer 轮询 cron) │
│ ★ AlertService: 告警记录 + /notify/{account}/# 通知(新增) │
│ ★ OtaService: 固件/版本管理 + 升级指令(新增) │
│ ★ GroupService: 设备分组(新增) │
└───────────────────────────────────────────────────────────┘
│ HTTPS REST │ MQTT(实时)
┌─ App 端 ─────────────────┼────────────────────┼──────────┐
│ 会员JWT │ │ │
│ 登录/注册(/api/v1/user) │ │ │
│ 设备绑定/列表/控制 │ │ │
│ 分组管理 / 场景管理 │ │ │
│ 告警列表 / OTA 查询 │ │ │
│ 实时状态订阅(/devices/+/state) │
│ 实时通知订阅(/notify/{account}/#) │
└───────────────────────────────────────────────────────────┘
```
---
## 三、Topic 规范(完整)
沿用现有 `/devices/{uuid}/...` 体系,扩展事件/OTA/通知:
| Topic | 方向 | 用途 |
|---|---|---|
| `/devices/{uuid}/state` | 设备→云 | 状态上报(retain=1=影子),**已有** |
| `/devices/{uuid}/cmd` | 云→设备 | 控制命令下发,**已有** |
| `/devices/{uuid}/event` | 设备→云 | 事件上报(非状态,如"门被打开"),新增 |
| `/devices/{uuid}/ota` | 云→设备 | OTA 升级指令,新增 |
| `/devices/{uuid}/ota/report` | 设备→云 | OTA 进度/结果上报,新增 |
| `/notify/{account}/#` | 云→App | 会员通知(告警/场景执行结果),新增 |
ACL 同步扩展:
- 设备规则 `auto:device:{uuid}` 已含 `/devices/%u/#`(覆盖新增 event/ota 子主题,无需改)。
- 会员规则 `auto:member:{uuid}:*` 需新增 `/devices/%s/state` 与 `event` 只读订阅(命令仍走 REST 下发,避免复杂化)。
- 新增 `auto:notify:{account}`:`/notify/{account}/#` 只读,绑定设备时一并生成。
---
## 四、数据库表设计(新增)
> 前缀约定 `__PREFIX__` = 实际 prefix(如 `wxapp_`);时间字段 int 时间戳。
### 1. `__PREFIX__iot_group` —— 设备分组(房间/自定义)
```sql
CREATE TABLE IF NOT EXISTS `__PREFIX__iot_group` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`member_id` int unsigned NOT NULL DEFAULT '0' COMMENT '归属会员(0=后台全局分组)',
`title` varchar(64) NOT NULL DEFAULT '' COMMENT '分组名',
`type` varchar(16) NOT NULL DEFAULT 'room' COMMENT 'room=房间 custom=自定义分组',
`sort` int NOT NULL DEFAULT '0',
`create_at` int DEFAULT NULL,
`update_at` int DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_member` (`member_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='设备分组';
```
### 2. `__PREFIX__iot_group_device` —— 分组-设备关联
```sql
CREATE TABLE IF NOT EXISTS `__PREFIX__iot_group_device` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`group_id` int unsigned NOT NULL DEFAULT '0',
`device_id` int unsigned NOT NULL DEFAULT '0' COMMENT 'iot_device.id',
`create_at` int DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_group_device` (`group_id`,`device_id`),
KEY `idx_device` (`device_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='分组设备关联';
```
### 3. `__PREFIX__iot_scene` —— 场景
```sql
CREATE TABLE IF NOT EXISTS `__PREFIX__iot_scene` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`member_id` int unsigned NOT NULL DEFAULT '0' COMMENT '归属会员(0=后台全局场景)',
`title` varchar(64) NOT NULL DEFAULT '' COMMENT '场景名',
`icon` varchar(32) NOT NULL DEFAULT '' COMMENT '图标标识',
`enabled` tinyint(1) NOT NULL DEFAULT '1' COMMENT '1启用 0停用',
`remark` varchar(255) DEFAULT NULL,
`create_at` int DEFAULT NULL,
`update_at` int DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_member` (`member_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='场景';
```
### 4. `__PREFIX__iot_scene_trigger` —— 场景触发器
```sql
CREATE TABLE IF NOT EXISTS `__PREFIX__iot_scene_trigger` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`scene_id` int unsigned NOT NULL DEFAULT '0',
`type` varchar(16) NOT NULL DEFAULT 'device_state' COMMENT 'device_state=设备状态/事件 schedule=定时 manual=手动',
`topic` varchar(255) NOT NULL DEFAULT '' COMMENT 'device_state: 主题过滤器(默认 /devices/+/state)',
`condition_json` text COMMENT '条件 JSON(见下)',
`cron` varchar(64) NOT NULL DEFAULT '' COMMENT 'schedule 用: 5段cron,如 0 8 * * *',
`enabled` tinyint(1) NOT NULL DEFAULT '1',
`create_at` int DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_scene` (`scene_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='场景触发器';
```
`condition_json` 约定(device_state 触发时按顺序全部满足才触发):
```json
[
{ "field": "temperature", "op": "gt", "value": 60 },
{ "field": "status", "op": "eq", "value": "on", "ref": "/devices/dev_abc123/state" }
]
```
- `field`:payload 内 JSON 字段路径(点分,如 `pos.0.t`),缺省按整条 payload 求值。
- `op`:`eq/neq/gt/gte/lt/lte/exists/contains`。
- `value`:比较值。
- `ref`(可选):引用另一设备的影子(`/devices/{uuid}/state`),不写则取**当前触发消息**的 payload。
> **state 报文改过三轮键名,路径请按 v1.7.0 词汇写**:
> `pos[下标]`(**下标即位置序号,已没有 `index`**)+ `t` / `df` / `pr[[身份, 温度]]`。
> 已配置的旧路径由 `SceneService::dig()` 在 payload 是新格式时自动改写,无需手工迁移:
>
> | 旧(≤1.4.0) | 旧(v1.5.0 / v1.6.0) | 现在(v1.7.0) |
> |---|---|---|
> | `data.0.t` | `positions.0.t` | `pos.0.t` |
> | `data.0.df` | `positions.0.df` | `pos.0.df` |
> | `data.0.vs.1` | `positions.0.probes.1.1` | `pos.0.pr.1.1` |
> | — | `positions.0.probes.1.0` | `pos.0.pr.1.0` |
> | 顶层 `iv` | `interval` | `iv` |
>
> `pr`(v1.6.0 起叫 `probes`)从「对象数组」变成了「二元组数组」,所以 `.N` 后面那一段从
> **字段名**变成了**分量下标**(`0`=身份、`1`=温度)—— 这是旧路径唯一需要"猜"的地方,
> 改写规则就是按这个映射。
>
> 两个**迁不了**的(路径取不到值 → 条件恒不成立,必须人工改):
> - `v`(旧的有效性标志):v1.5.0 起字段已删,等价于「`t !== null`」→
> 改用对 `pos.N.t` 判 `exists` / `gt`
> - `i` / `index`(位置号):v1.6.0 起位置序号就是数组下标,报文里不再有这个字段
>
> ⚠️ 场景路径会"跟着线上词汇走",而本项目已经改过三轮(`data`→`positions`→`pos`、
> `iv`→`interval`→`iv`)。**每次改报文键名都必须同步 `SceneService::upgradeStatePath()` 的
> `$map`**,否则库里已存的场景条件会取不到值 → **静默**恒不成立(不报错,最难查)。
>
> 例:`{"field":"pos.0.t","op":"gt","value":60}` = 位置 0 平均温度超 60℃。
### 5. `__PREFIX__iot_scene_action` —— 场景动作
```sql
CREATE TABLE IF NOT EXISTS `__PREFIX__iot_scene_action` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`scene_id` int unsigned NOT NULL DEFAULT '0',
`sort` int NOT NULL DEFAULT '0',
`type` varchar(16) NOT NULL DEFAULT 'send_cmd' COMMENT 'send_cmd=下发命令 http=HTTP回调 notify=站内告警',
`device_uuid` varchar(64) NOT NULL DEFAULT '' COMMENT 'send_cmd: 目标设备',
`payload_json` text COMMENT 'send_cmd: 命令payload(JSON) / http: 请求体',
`topic` varchar(255) NOT NULL DEFAULT '' COMMENT 'http: 回调URL 或 notify: 通知标题',
`create_at` int DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_scene` (`scene_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='场景动作';
```
动作语义:
- `send_cmd`:`Store::enqueueManual('/devices/{uuid}/cmd', payload_json)` 下发命令(在线即达,离线入队)。
- `http`:`topic` 为回调 URL,`payload_json` 为请求体,POST 触发(可对接第三方 webhook/小程序推送)。
- `notify`:写 `iot_alert`(站内告警),并 retain 到 `/notify/{account}/notice`。
### 6. `__PREFIX__iot_scene_log` —— 场景执行日志
```sql
CREATE TABLE IF NOT EXISTS `__PREFIX__iot_scene_log` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`scene_id` int unsigned NOT NULL DEFAULT '0',
`trigger_type` varchar(16) NOT NULL DEFAULT '',
`matched` tinyint(1) NOT NULL DEFAULT '0' COMMENT '1命中 0未命中',
`message` text COMMENT '命中摘要/失败原因',
`create_at` int DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_scene` (`scene_id`),
KEY `idx_create_at` (`create_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='场景执行日志';
```
### 7. `__PREFIX__iot_ota` —— 固件版本
```sql
CREATE TABLE IF NOT EXISTS `__PREFIX__iot_ota` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`model` varchar(64) NOT NULL DEFAULT '' COMMENT '适用设备型号',
`version` varchar(32) NOT NULL DEFAULT '' COMMENT '版本号',
`filename` varchar(128) NOT NULL DEFAULT '' COMMENT '固件文件名',
`file_hash` varchar(64) NOT NULL DEFAULT '' COMMENT 'SHA256',
`size` int unsigned NOT NULL DEFAULT '0' COMMENT '字节数',
`url` varchar(255) NOT NULL DEFAULT '' COMMENT '固件下载地址',
`remark` varchar(255) DEFAULT NULL,
`status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '1可下发 0停用',
`create_at` int DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_model` (`model`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='OTA固件版本';
```
### 8. `__PREFIX__iot_device_log` —— 设备日志(设备维度)
```sql
CREATE TABLE IF NOT EXISTS `__PREFIX__iot_device_log` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`uuid` varchar(64) NOT NULL DEFAULT '' COMMENT '设备uuid',
`topic` varchar(255) NOT NULL DEFAULT '',
`payload` longtext,
`direction` varchar(8) NOT NULL DEFAULT 'in' COMMENT 'in=设备上报 out=下发',
`create_at` int DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_uuid` (`uuid`),
KEY `idx_create_at` (`create_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='设备日志';
```
### 9. `__PREFIX__iot_alert` —— 告警记录
```sql
CREATE TABLE IF NOT EXISTS `__PREFIX__iot_alert` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`member_id` int unsigned NOT NULL DEFAULT '0' COMMENT '归属会员',
`uuid` varchar(64) NOT NULL DEFAULT '' COMMENT '关联设备(可空)',
`type` varchar(32) NOT NULL DEFAULT '' COMMENT 'offline/scene/condition/custom',
`level` tinyint(1) NOT NULL DEFAULT '2' COMMENT '1提示 2警告 3严重',
`title` varchar(128) NOT NULL DEFAULT '',
`content` varchar(512) NOT NULL DEFAULT '',
`is_read` tinyint(1) NOT NULL DEFAULT '0',
`create_at` int DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_member` (`member_id`),
KEY `idx_create_at` (`create_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='告警记录';
```
### 自愈建表
所有新表由 `Auth::ensureTables()`(或独立 `SchemaService`)追加 `CREATE TABLE IF NOT EXISTS`,遵循现有自愈模式,老库无需重装即可用。
---
## 五、服务层模块设计(新增)
### 1. `SceneService`(场景/自动化引擎)—— 核心
```
事件流:
Broker::handlePublish
├─ 持久化 retain / message 日志
├─ 写 iot_device_log(uuid 维度,若 topic 属于 /devices/{uuid}/#)
└─ event('mqtt_message_published', [topic, payload, client_id])
└─ SceneService::evaluate($topic, $payload, $clientId) // 同步评估,动作异步投递
```
```php
class SceneService
{
/** 规则内存缓存(TTL 30s,同 Auth ACL 模式;Broker 单进程,进程内安全) */
protected static array $sceneCache = [];
protected static int $loadedAt = 0;
/** Broker 事件钩子入口 */
public static function onMessagePublished(string $topic, string $payload, string $clientId): void;
/** 评估:遍历启用场景的 device_state 触发器 */
protected static function matchTrigger(array $scene, string $topic, string $payload): ?array;
/** 条件解析:condition_json + 可选 ref 影子读取 */
protected static function matchCondition(array $conds, string $payload, string $topic): bool;
/** 执行动作(全部入 outbox 异步投递,不阻塞 Broker) */
protected static function runActions(array $scene, string $triggerType): void;
/** 定时触发:Workerman Timer 每分钟扫描 cron 命中场景(单独挂载) */
public static function onTick(): void;
/** 手动触发(App/后台一键执行场景) */
public static function runScene(int $sceneId): array;
}
```
**设计要点**:
- 评估在 Broker 进程内同步进行,但**只做内存判断**;动作一律 `Store::enqueueManual()` 异步投递,DB 写操作轻量(日志/告警),不影响消息分发主循环。
- 引用其他设备影子时读 `iot_retain`(或 Broker 进程内 retained 缓存),避免实时依赖。
- `cron` 解析:5 段(分 时 日 月 周),实现一个最小解析器(约 40 行),不引入第三方依赖。
### 2. `GroupService`(设备分组)
```php
class GroupService
{
public static function ensureTable(): bool;
public static function create(int $memberId, string $title, string $type = 'room'): array;
public static function rename(int $groupId, int $memberId, string $title): void;
public static function remove(int $groupId, int $memberId): void;
public static function groups(int $memberId): array; // 含每组的设备数/在线数
public static function addDevice(int $groupId, int $deviceId): void;
public static function removeDevice(int $groupId, int $deviceId): void;
public static function devicesByGroup(int $groupId): array; // 复用 DeviceService::myDevices 增强字段
}
```
### 3. `AlertService`(告警/通知)
```php
class AlertService
{
public static function ensureTable(): bool;
public static function add(int $memberId, string $type, int $level, string $title, string $content, string $uuid = ''): int;
public static function push(int $memberId, string $topic, string $payload): void; // retain 到 /notify/{account}/{topic}
public static function list(int $memberId, int $page = 1, int $size = 20): array;
public static function markRead(int $memberId, array $ids = []): void;
public static function onDeviceOffline(string $uuid): void; // 可选:断线遗嘱触发离线告警
}
```
### 4. `OtaService`(固件升级)
```php
class OtaService
{
public static function ensureTable(): bool;
public static function upload(string $model, string $version, string $file, string $url): array; // 计算SHA256/大小
public static function list(string $model = ''): array;
public static function publish(int $otaId, array $deviceUuids): void; // 逐个 enqueueManual 到 /devices/{uuid}/ota
public static function onReport(string $uuid, string $payload): void; // 解析 /ota/report 进度写日志/告警
}
```
---
## 六、App 端 API 接口清单
> 统一前缀 `/iot/api/client/`,需会员 JWT。响应 `{code, msg, data, timestamp}`,code=0 成功。
### 分组(GroupService)
| 方法 | 接口 | 参数 | 说明 |
|---|---|---|---|
| GET | `groupList` | — | 我的分组(含设备数/在线数) |
| POST | `saveGroup` | `title`, `type`, `id?` | 新建/编辑分组 |
| POST | `deleteGroup` | `id` | 删除分组 |
| POST | `groupAddDevice` | `group_id`, `device_id` | 设备加入分组 |
| POST | `groupRemoveDevice` | `group_id`, `device_id` | 移出分组 |
| GET | `groupDevices` | `group_id` | 组内设备(含影子/在线) |
### 场景(SceneService)
| 方法 | 接口 | 参数 | 说明 |
|---|---|---|---|
| GET | `sceneList` | — | 我的场景列表 |
| GET | `sceneDetail` | `id` | 场景详情(触发器+动作) |
| POST | `saveScene` | `title`, `icon`, `triggers[]`, `actions[]`, `id?` | 新建/编辑(triggers/actions 为 JSON 数组字符串) |
| POST | `deleteScene` | `id` | 删除 |
| POST | `toggleScene` | `id`, `enabled` | 启用/停用 |
| POST | `runScene` | `id` | 手动执行(触发 manual 型动作) |
| GET | `sceneLogs` | `id`, `page` | 场景执行日志 |
### 告警(AlertService)
| 方法 | 接口 | 参数 | 说明 |
|---|---|---|---|
| GET | `alertList` | `page`, `size`, `is_read?` | 我的告警 |
| POST | `readAlert` | `ids[]` | 标记已读 |
| GET | `alertUnread` | — | 未读数(App 角标轮询) |
### 设备日志 / OTA
| 方法 | 接口 | 参数 | 说明 |
|---|---|---|---|
| GET | `deviceLogs` | `uuid`, `page` | 设备日志(in/out) |
| GET | `otaList` | `model` | 可用固件列表 |
| POST | `publishOta` | `ota_id`, `uuids[]` | App 触发升级(校验设备归属) |
### 实时通道(MQTT 直连,需 `allow_member_login`)
| 用途 | 订阅/发布 | 说明 |
|---|---|---|
| 设备实时状态 | 订阅 `/devices/{uuid}/state` | ACL `auto:member:{uuid}:state` 已授权 |
| 事件流 | 订阅 `/devices/{uuid}/event` | 新增只读授权 |
| 通知 | 订阅 `/notify/{account}/#` | 新增 `auto:notify:{account}` 只读授权 |
| 控制 | 发布 `/devices/{uuid}/cmd` | ACL 已授权(或走 REST `sendCommand`) |
---
## 七、后台管理接口清单
> 前缀 `/iot/backend/...`,AdminAuth 鉴权。
| 方法 | 接口 | 说明 |
|---|---|---|
| GET | `group` | 分组管理页 |
| POST | `saveGroup` | 新建/编辑(全局分组 member_id=0) |
| POST | `deleteGroup` | 删除 |
| GET | `scene` | 场景管理页(全局场景) |
| POST | `saveScene` / `deleteScene` | 场景维护 |
| GET | `sceneLogs` | 场景日志页 |
| GET | `ota` | 固件管理页 |
| POST | `uploadOta` / `deleteOta` / `publishOta` | 固件维护/下发 |
| GET | `alerts` | 告警列表页 |
| POST | `readAlert` / `deleteAlert` | 告警处理 |
| GET | `deviceLogs` | 设备日志页(按 uuid 过滤) |
菜单 `menu.json` 新增子项:智能设备(已有)→ 分组管理 / 场景联动 / 固件升级 / 告警中心(sort 7.x)。
---
## 八、场景引擎触发时序
```
设备 publish /devices/dev_x/state payload:{"temperature":65,"status":"on"}
│
▼ Broker::handlePublish
├─ retain 持久化(影子更新)
├─ message 日志 + device_log(uuid 维度)
├─ 转发给在线订阅者(App 实时)
└─ event('mqtt_message_published')
│
▼ SceneService::onMessagePublished
├─ 加载启用场景(内存缓存 TTL30s)
├─ 逐个匹配 device_state 触发器:
│ ├─ topic 匹配(/devices/+/state ✓)
│ ├─ 条件全部满足?(temperature>60 ✓)
│ └─ 命中 → 该场景进入执行
└─ runActions(scene)
├─ 写 scene_log(matched=1)
├─ action[send_cmd] → Store::enqueueManual('/devices/dev_y/cmd', {"power":"on"})
├─ action[http] → HTTP POST webhook(异步)
└─ action[notify] → AlertService::add + push('/notify/{account}/notice')
│
▼ Broker 消费 outbox → 下发到设备 /devices/dev_y/cmd
▼ App 收到 /notify/{account}/notice(在线即推 / retain 兜底)
```
**定时触发器**:Workerman Timer(每分钟)→ `SceneService::onTick()` → 匹配 `cron` 命中的 schedule 型场景 → `runActions`。手动触发:App `runScene` → 直接执行对应场景动作。
---
## 九、OTA 流程
```
后台上传固件(uploadOta) → 计算SHA256/大小 → 存 iot_ota
│
▼ 选中设备 publishOta
Store::enqueueManual('/devices/{uuid}/ota',
{"version":"1.2.0","url":"https://...","hash":"...","size":12345})
│
▼ 设备收到 → 下载固件 → 校验哈希 → 升级
设备上报 /devices/{uuid}/ota/report {"state":"downloading","progress":42}
│
▼ Broker → OtaService::onReport()
├─ 写 device_log
├─ 进度变更 → /notify/{account}/ota 通知
└─ state=done/fail → 告警/提示
```
---
## 十、告警触发源汇总
| 触发源 | 方式 | 说明 |
|---|---|---|
| 设备离线 | Broker 遗嘱消息 → `AlertService::onDeviceOffline` | 优雅/非优雅断开均可达(可选开关) |
| 场景条件命中 | `runActions` 内 `notify` 动作 | 与场景引擎天然复用 |
| 设备主动上报 | 设备 publish 到 `/devices/{uuid}/event` | payload 含 level/title/content 即转告警 |
| OTA 失败 | `OtaService::onReport` 异常分支 | 升级失败告警 |
---
## 十一、实施路线图(分阶段)
| 阶段 | 内容 | 交付物 | 依赖 |
|---|---|---|---|
| **P0** | 前置收敛:确认清理 `runtime/route_list.php` 使现有 `/iot/api/client/*` 路由生效 | 路由联调 | — |
| **P1** | 基础增强:`device_log` + `alert` 表、Broker 钩子写日志、遗嘱离线告警 | `DeviceLogService`/`AlertService` | — |
| **P2** | 场景引擎:`scene/trigger/action/log` 表 + `SceneService` + cron 定时器 + 后台管理页 + App API | 场景自动化核心闭环 | P1 |
| **P3** | 设备分组:`group` 表 + `GroupService` + 后台 + App API + `/notify` 主题与 ACL | 分组与通知 | P2 |
| **P4** | OTA:`ota` 表 + `OtaService` + 后台上传/下发 + 设备上报处理 | 固件升级闭环 | P3 |
| **P5** | App 实时推送文档 + 端到端联调验证脚本 | 验证报告 | P2-P4 |
**每阶段验收**:后台可用 + App API 可用 + `runtime/_devtest.php` 自动化断言通过(沿用现有测试脚本模式)。
---
## 十二、风险与注意
1. **Broker 单进程**:场景评估是同步的,条件/动作必须轻量;动作入 outbox 异步投递,禁止在评估中同步发送 MQTT。
2. **cron 解析**:实现最小 5 段解析器即可(不支持秒/年,避免依赖)。
3. **路由缓存**:新增后台/API 路由后必须清 `runtime/route_list.php`(沿用 `Remove-Item runtime/route_list.php`)。
4. **自愈**:所有新表走 `ensureTables()` 自愈建表,老库升级无需重装插件。
5. **命名**:新表/字段遵守框架铁律(禁裸 `name`、`__PREFIX__`、`create_at/update_at/delete_at`)。
6. **ACL 安全**:`/notify/{account}/#` 只能放行对应账号,绑定/解绑设备时同步生成/清理 `auto:notify:{account}` 规则(同 `syncMemberAcl`/`clearMemberAcl` 模式)。
+56
View File
@@ -0,0 +1,56 @@
# 巡检信息与清理缓存
内置的「系统管理 → 系统信息」页面已于 2026-09-28 下线(System 控制器 / `view/system` / `modules/system.js` 全部删除),
原因很直接:它展示的东西控制台首页顶部一字不差地都有(同一份 `SystemInfo::collect()`),留着就是两份要维护。
现在这两件事都在**控制台首页**(`/admin/console/index.html`,`app/backend/controller/Console.php`):
- 巡检信息:`Console::index()` 渲染,数据来自 `app/backend/library/SystemInfo.php`
- 清理缓存:`Console::clean()`,权限 `console:clean`(挂在「控制台」菜单下)
## 页面内容(控制台首页)
| 区块 | 说明 |
|------|------|
| 运行统计 | 管理员数、插件数、附件数、数据库占用(走 `DbBackup::tables()` 汇总) |
| 运行环境 | YwxApp / ThinkPHP / PHP / MySQL 版本、系统、服务器时间、上传限制、内存限制、脚本超时、禁用函数 |
| 磁盘 | 已用 / 总量 / 剩余,进度条(取 `app()->getRootPath()` 所在盘) |
| 目录权限 | `runtime` / `public/storage` / `config` / `addon` 是否存在、是否可写;不可写会导致缓存无法生成、上传失败、插件装不上 |
| 维护 | 清理数据缓存 / 模板缓存 / 全量清理(含路由缓存) |
| 安全提示 | 近 7 天操作条数、其中登录失败次数 |
| 近 7 天趋势 | 会员注册数、后台操作条数(真数据,`Console::collectStats()` 产出,ECharts 渲染) |
| 最新会员 / 最近操作日志 | 各取最近 8 条真数据 |
## 清理缓存
```text
POST /admin/console/clean body: type=cache|temp|all
```
- `cache` → `runtime/cache`
- `temp` → `runtime/temp`
- `all` → 上述两个 + `runtime/route_list.php`(下次请求自动重建)
**不碰 `runtime/log`**:操作日志(`docs/guide/operation_log.md`)与错误日志要留着。
清理动作本身会记一条 `console/clean` 日志(写在 `SystemInfo::cleanRuntime()` 里)。
## 巡检数据怎么取(给排障用)
- MySQL 版本:`Db::query('SELECT VERSION()')`
- 磁盘:`disk_total_space()` / `disk_free_space()` —— 注意这俩返回 **float**,
`formatBytes()` 参数必须收 float,否则严格类型下直接 TypeError(页面 500)。
- 统计用 `SystemInfo::safeCount()` 包一层:表缺失或查询异常时返回 0,
首页不会因为某张插件表没建就整体打不开。`Console::collectStats()` 里的逐个查询同理。
## 老库迁移
已装好的库不会自动跟着变(权限表没有自删逻辑),跑一次一次性脚本即可:
```bash
php scripts/migrate_console_clean.php # 预演
php scripts/migrate_console_clean.php --force # 执行
```
做的事:删掉 `system:index` 页面节点及其子权限与角色授权,把 `system:clean` **就地改名**为
`console:clean`(Route 同步改 `console/clean`,挂到「控制台」下)。
就地改名而不是新建,是为了保住 id ⇒ 保住角色授权表里既有的绑定。