iotsaas/docs/设备动态(操控记录)设计方案.md
2995840376 0ce191c448 chore: 初始化 iotsaas 版本库基线
纳入范围:
- 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
2026-10-27 12:00:41 +08:00

229 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 设备操控动态(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)?