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

+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` 模式)。