纳入范围: - backend/ Spring Boot 多模块后端(iot-common/device/rule/data/alarm/auth) - frontend/admin/ Vue3 + Element Plus 管理后台 - Android/ 涂鸦原生 APP (com.xhai.iot) - backend/sql/ Flyway 风格迁移脚本(V2~V21) - docs/ 对接文档与设计文档 排除范围: - 构建产物: node_modules/ target/ build/ dist/ .gradle/ - 签名密钥: *.jks *.keystore (严禁入库) - 本机配置: Android/local.properties - 运行日志: logs/ *.log - AI 协作本地数据 .codebuddy/ 与第三方 demo tuya-demo/ .gitattributes 统一以 LF 存储, Windows 批处理保留 CRLF
229 lines
11 KiB
Markdown
229 lines
11 KiB
Markdown
# 设备操控动态(Activity)统一方案
|
||
|
||
> 目标:**以 APP 自己的控制 + 监听到的信息为第一数据源**形成操控动态,再逐步对接各平台(涂鸦/米家/华为/Matter)的云端动态。
|
||
> 编写日期:2026-09-20 | 状态:方案待评审
|
||
|
||
---
|
||
|
||
## 1. 现状盘点
|
||
|
||
### 1.1 已有资产
|
||
|
||
| 资产 | 位置 | 说明 |
|
||
|---|---|---|
|
||
| `device_activity_log` 表 | iot-device | 动态存储,字段见 1.2 |
|
||
| `DeviceActivityService` | iot-device/service | `record()` 异步写入、`hasRecentActivity()` 查重、`buildDpsDesc()` 描述生成 |
|
||
| `DeviceActivityController` | iot-device/controller | `GET /devices/activities/recent`(首页)、`GET /devices/activities`(分页) |
|
||
| 物模型 / 标准控制模型 | `product` + `product_property` + `ControlSchemaService` | **可把原始 DP 翻译成人类可读语义**(P3 已落地) |
|
||
| 平台抽象层 | `PlatformServiceManager` / `IPlatformService` | 多平台统一控制入口 |
|
||
|
||
### 1.2 当前表结构
|
||
|
||
```
|
||
id, home_id, owner_id, device_id, device_name,
|
||
action, action_desc, source, operator_member_id, detail, create_time
|
||
```
|
||
|
||
### 1.3 现有记录点(4 个)
|
||
|
||
| # | 位置 | 触发 | source |
|
||
|---|---|---|---|
|
||
| 1 | `TuyaController.sendCommand` | 经后端下发的指令(云端通道) | app / voice |
|
||
| 2 | `TuyaController.syncDeviceStatus`(`/tuya/devices/status`) | APP 上报 DP 变化 | panel |
|
||
| 3 | `TuyaMessageSubscribeService` | 涂鸦云端推送(Pulsar) | cloud_push |
|
||
| 4 | `DeviceActivityController.report`(`/devices/{id}/activity`,本期新增) | APP 走 SDK 直连后主动上报 | app |
|
||
|
||
### 1.4 已暴露的问题
|
||
|
||
1. **APP 走 SDK 直连,后端天然感知不到**:控制不经过后端 `/commands`,动态全靠"上报"补(记录点 4 是补丁,不是终态)。
|
||
2. **语义弱**:`buildDpsDesc()` 只识别 bool,输出`打开/关闭 设备名`;不懂物模型,亮度/模式/枚举都只能输出"状态更新(N 项)"。
|
||
3. **来源语义重叠,易双记或漏记**:一次真实操作常同时触发 记录点 2 和 3,只能靠 8 秒查重粗略兜底。
|
||
4. **平台耦合**:涂鸦专属逻辑(Pulsar 解析、`/tuya/...` 接口)散落在 `TuyaController`、`TuyaMessageSubscribeService`,接米家/华为/Matter 要重来一遍。
|
||
5. **休眠设备天然无动态**:蓝牙 Mesh 子设备(JD6BTSA / 门磁)休眠不上报,云端与本地都可能感知不到,属设备物理特性。
|
||
6. **历史数据为空**:`device_activity_log` 长期 0 条(本期已修复涂鸦推送解析 bug:`bizCode/bizData` 在 `data` 内)。
|
||
|
||
---
|
||
|
||
## 2. 设计目标
|
||
|
||
- **平台无关**:动态模型不绑定任何平台,平台差异只体现在"采集适配"层。
|
||
- **两类来源统一**:主动控制(Command)与被动监听(Observe)归一到同一张表、同一套语义。
|
||
- **语义可读**:结合物模型把 `{"1":true}` 翻译成"打开 客厅灯"。
|
||
- **去重可控**:控制与其回执只记一条;短窗口内同设备同功能点合并。
|
||
- **渐进对接**:先把"自己的"做扎实,再按统一接口接各平台。
|
||
|
||
---
|
||
|
||
## 3. 总体架构
|
||
|
||
```
|
||
┌─────────────── 采集层 Collector(三类,平台相关) ───────────────┐
|
||
│ ① CommandCollector 我们自己的控制入口 │
|
||
│ APP: PlatformServiceManager.controlDevice 成功回调 │
|
||
│ 后端: /commands(云端下发)、语音、场景执行 │
|
||
│ │
|
||
│ ② ObserveCollector APP 本地监听(SDK 直连,最实时) │
|
||
│ DP 变化 / 上下线(涂鸦 Mesh、蓝牙、局域网) │
|
||
│ │
|
||
│ ③ PlatformEventCollector 各平台云端事件(后续对接) │
|
||
│ 涂鸦 Pulsar(已有)→ 米家 / 华为 / Matter(后续) │
|
||
└──────────────────────────┬───────────────────────────────────────┘
|
||
↓
|
||
归一化层 Normalizer → DeviceEvent(平台无关)
|
||
{ deviceId, platform, type, dpCode, value, ts, raw }
|
||
↓
|
||
语义层 Enricher(查 product + product_property + ControlSchema)
|
||
dpId → code → name / type / enum → "打开 客厅灯"、"亮度调到 60%"
|
||
↓
|
||
去重层 Dedupe(commandId 配对 + 短窗口合并)
|
||
↓
|
||
存储 device_activity_log(扩展字段,见第 5 节)
|
||
↓
|
||
消费:首页近期动态 / 动态详情 / 规则引擎 / 中控屏 / WebSocket 推送
|
||
```
|
||
|
||
**关键:采集层可插拔,下面三层完全平台无关。** 新增平台 = 新增一个 Collector/Adapter。
|
||
|
||
---
|
||
|
||
## 4. 统一事件模型
|
||
|
||
```java
|
||
public class DeviceEvent {
|
||
private String deviceId; // 平台设备ID
|
||
private String platform; // tuya / mi / huawei / matter / local
|
||
private EventType type; // CONTROL / STATE / ONLINE / OFFLINE / ALARM
|
||
private String dpCode; // 物模型功能点编码(平台无关语义 key,如 switch_1)
|
||
private String dpId; // 平台原始 DP 点(如涂鸦 "1")
|
||
private Object value; // 归一化后的值
|
||
private Long homeId;
|
||
private Long operatorMemberId;// 操作人(主动控制有,被动上报为 null)
|
||
private String commandId; // 控制回执配对 ID
|
||
private Long ts;
|
||
private Object raw; // 原始报文(审计/排查用)
|
||
}
|
||
```
|
||
|
||
`EventType`:
|
||
- **CONTROL**:我方发起并确认成功的操作(最可信,优先级最高)
|
||
- **STATE**:状态变化(DP 上报/云端推送)
|
||
- **ONLINE / OFFLINE**:上下线
|
||
- **ALARM**:告警类(门磁触发、烟雾报警等,后续)
|
||
|
||
---
|
||
|
||
## 5. 数据模型演进(V15 迁移草案)
|
||
|
||
在现有 `device_activity_log` 上**新增列**(保留原字段,保证读兼容):
|
||
|
||
| 新增列 | 类型 | 说明 |
|
||
|---|---|---|
|
||
| `platform` | varchar(32) | 平台标识 |
|
||
| `event_type` | varchar(16) | control / state / online / offline / alarm |
|
||
| `dp_code` | varchar(64) | 物模型功能点编码(语义 key) |
|
||
| `value_text` | varchar(128) | 归一化值("true" / "60%" / "cold") |
|
||
| `command_id` | varchar(64) | 控制回执配对,用于去重 |
|
||
| `dedupe_key` | varchar(128) | 去重键,**唯一索引** |
|
||
| `raw` | json | 原始平台报文(detail 保留兼容,新数据同时写 raw) |
|
||
|
||
索引:`idx_home_time(home_id, create_time)`、`idx_device_time(device_id, create_time)`、`uniq(dedupe_key)`
|
||
|
||
**为什么加 `dedupe_key` 而不是继续用 8 秒查重**:查重是"启发式",去重键是"确定性"——由 `commandId` 或 `deviceId + dpCode + 时间桶` 生成,重复写入直接被唯一索引拦截,不依赖时序。
|
||
|
||
---
|
||
|
||
## 6. 关键机制
|
||
|
||
### 6.1 控制—回执配对(解决"一次操作记两条")
|
||
|
||
1. 发起控制时生成 `commandId`(客户端 UUID 或后端生成)
|
||
2. 控制成功 → 写 `CONTROL` 记录(带 commandId)
|
||
3. 随后到达的 STATE(同 commandId,或 同设备+同 dpCode+10s 内)→ **只更新该 CONTROL 记录,不新增**
|
||
4. 超窗仍未配对 → CONTROL 记录保持("指令已下发"),后续 STATE 独立成条
|
||
|
||
### 6.2 去重规则
|
||
|
||
| 场景 | 策略 |
|
||
|---|---|
|
||
| 控制 + 其回执 | commandId 配对,合并为 1 条 |
|
||
| 本地监听 + 云端推送(同一变化) | `deviceId + dpCode + 5s 时间桶` 去重键 |
|
||
| 上下线抖动 | 同设备 60s 内同状态只记 1 条 |
|
||
| 场景批量执行 | 按场景维度聚合 1 条("执行场景:回家模式"),不逐设备刷屏 |
|
||
|
||
去重窗口可配置(`iot.device.activity.dedupe.*`)。
|
||
|
||
### 6.3 语义化(重点,复用 P3 物模型)
|
||
|
||
映射链:**平台 dpId → product_property.code → name / type / enum / unit**
|
||
|
||
```
|
||
原始: device=6c... dps={"1":true}
|
||
TSL : dpId "1" → code "switch_1" → name "开关" → type "bool"
|
||
输出: "打开 客厅灯"
|
||
|
||
原始: dps={"22":500}
|
||
TSL : dpId "22" → code "bright_value" → name "亮度" → min 0 / max 1000 / unit "%"
|
||
输出: "客厅灯 亮度调到 50%"
|
||
|
||
原始: dps={"1":"cold"}
|
||
TSL : dpId "1" → code "mode" → enum ["auto","cold","hot"]
|
||
输出: "空调 模式切换为 制冷"
|
||
```
|
||
|
||
**降级策略**:产品无物模型时,回退现有 `buildDpsDesc()`(bool→开/关,其余→"状态更新"),保证老产品不空白。
|
||
|
||
### 6.4 平台适配接口(后续对接统一入口)
|
||
|
||
```java
|
||
public interface IPlatformEventAdapter {
|
||
DevicePlatform platform();
|
||
/** 平台原始报文 → 统一事件 */
|
||
List<DeviceEvent> normalize(Object rawEvent);
|
||
/** 启动订阅/长连接,事件推给 sink */
|
||
void start(Consumer<List<DeviceEvent>> sink);
|
||
void stop();
|
||
}
|
||
```
|
||
|
||
- **涂鸦**:`TuyaPulsarEventAdapter`(把现有 `TuyaMessageSubscribeService` 的解析逻辑迁入,含新版 `bizCode/bizData` 处理)
|
||
- **米家 / 华为 / Matter**:按同一接口实现,业务层零改动
|
||
|
||
---
|
||
|
||
## 7. 与现有模块的关系
|
||
|
||
- 复用 **Product / ProductProperty / ControlSchemaService**(P3 成果)做语义翻译
|
||
- 复用 **DeviceEventPublisher**(RabbitMQ)把动态分发给规则引擎、中控屏 WebSocket
|
||
- 复用 **PlatformServiceManager** 作为所有控制的统一收口(已在成功回调埋点)
|
||
- 中控屏与 APP 共用同一份动态数据(`/api/v1/panel/...` 命名空间未来直接复用)
|
||
|
||
---
|
||
|
||
## 8. 分阶段计划
|
||
|
||
| 阶段 | 内容 | 产出 |
|
||
|---|---|---|
|
||
| **P0** | 表结构扩展(platform/event_type/dp_code/value_text/command_id/dedupe_key/raw)+ 去重键唯一索引 | V15 迁移 |
|
||
| **P1** | `DeviceEvent` 模型 + `ActivityPipeline`(归一化→语义→去重→存储);`commandId` 贯穿控制链路(APP 与后端) | 一次操作只记一条、语义可读 |
|
||
| **P2** | 语义层接入物模型(dpId→code→name/enum/unit);无物模型降级 | "亮度调到 50%" 级别描述 |
|
||
| **P3** | 采集层重构:`CommandCollector` / `ObserveCollector` / `PlatformEventAdapter`;涂鸦 Pulsar 迁入 Adapter | 平台解耦 |
|
||
| **P4** | 对接米家 / 华为 / Matter;场景聚合、告警类动态、保留策略与清理任务 | 多平台统一动态 |
|
||
|
||
---
|
||
|
||
## 9. 风险与边界
|
||
|
||
1. **休眠设备天然无动态**:蓝牙 Mesh 子设备(JD6BTSA / 门磁)不上报,任何方案都记不到。UI 应对此类设备明示"休眠设备,可能无法记录全部动态",避免用户误解为 bug。
|
||
2. **去重过激会漏记**:窗口需可配置,且保留 `raw` 原始报文便于回溯。
|
||
3. **数据增长**:建议保留策略(如 90 天滚动清理或按月归档),写入全异步不阻塞控制主链路。
|
||
4. **语义依赖物模型**:物模型未录入的产品只能降级输出,需引导在后台补齐(P3 已有录入界面)。
|
||
|
||
---
|
||
|
||
## 10. 待确认
|
||
|
||
1. `commandId` 由客户端生成还是后端下发?(建议客户端 UUID,随控制请求带给后端)
|
||
2. 场景/AI 触发的操作,`operator_member_id` 记触发人还是留空并记场景 ID?
|
||
3. 动态保留期限与归档策略?
|
||
4. 是否需要"动态订阅/推送"(设备动态实时推到 APP/中控屏 WebSocket)?
|