fix(miot): 米家已删除设备从缓存、控制器与订阅中彻底剔除

在米家 App 删除设备后设备列表仍能获取到:删除检测只 delete(deviceListCloud),
deviceListCache 仅置 online=false,而对外 DeviceList()/GetDevices() 读的正是
cache;缓存又会在停机时落盘到 .dict、启动时原样恢复,已删设备跨重启复活。

- pruneRemovedDevicesLocked:候选集改为「缓存 ∪ 云列表」,云端不存在且网关/
  局域网非在线的设备从四张表彻底删除(旧实现只遍历 deviceListCloud,启动时它
  为空,停机期间被删的设备永远检测不到)
- 护栏:云端返回空列表时跳过剔除,避免接口异常清库
- homeScope:只刷新部分家庭时不再误删其它家庭的云列表
- 子设备(xxx.s1)不参与判定,它被归并到父设备的 sub_devices
- 命中剔除的设备同时清理 c.devices 实例与 MQTT 订阅路由
- bridge: pruneControllers 剔除已删设备的控制器,AC()/Switch() 不再返回实例
- 新增 7 个 miot 用例(含 httptest 打桩的端到端)+ 3 个 bridge 用例
This commit is contained in:
2026-09-12 19:29:46 +08:00
parent c18c0adaae
commit a595330144
5 changed files with 437 additions and 15 deletions
+253
View File
@@ -2,6 +2,10 @@
package miot
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
@@ -289,3 +293,252 @@ func TestGetDeviceInfo_EmptyCache(t *testing.T) {
t.Errorf("GetDeviceInfo on empty cache should return nil, got %v", info)
}
}
// ============================================================================
// 米家删除设备必须从缓存中剔除
// ============================================================================
// TestPruneRemovedDevices_RemovesAbsentDevice 云端不再返回的设备要从三源 + 缓存里彻底剔除。
func TestPruneRemovedDevices_RemovesAbsentDevice(t *testing.T) {
c := NewMIoTClient("test", nil, "uid", "cn", CtrlModeCloud)
defer c.Stop()
c.deviceListMu.Lock()
c.deviceListCache["did-keep"] = map[string]interface{}{"did": "did-keep", "home_id": "h1", "online": true}
c.deviceListCache["did-gone"] = map[string]interface{}{"did": "did-gone", "home_id": "h1", "online": true}
c.deviceListCloud["did-gone"] = map[string]interface{}{"did": "did-gone", "home_id": "h1", "online": true}
c.deviceListGateway["did-gone"] = map[string]interface{}{"did": "did-gone", "online": false}
c.deviceListLan["did-gone"] = map[string]interface{}{"did": "did-gone", "online": false}
removed := c.pruneRemovedDevicesLocked(map[string]map[string]interface{}{
"did-keep": {"did": "did-keep", "home_id": "h1", "online": true},
}, nil)
if len(removed) != 1 || removed[0] != "did-gone" {
t.Fatalf("removed = %v, want [did-gone]", removed)
}
if _, ok := c.deviceListCache["did-gone"]; ok {
t.Error("deviceListCache 应剔除已删设备")
}
if _, ok := c.deviceListCloud["did-gone"]; ok {
t.Error("deviceListCloud 应剔除已删设备")
}
if _, ok := c.deviceListGateway["did-gone"]; ok {
t.Error("deviceListGateway 应剔除已删设备")
}
if _, ok := c.deviceListLan["did-gone"]; ok {
t.Error("deviceListLan 应剔除已删设备")
}
if _, ok := c.deviceListCache["did-keep"]; !ok {
t.Error("云端仍存在的设备不应被剔除")
}
c.deviceListMu.Unlock()
}
// TestPruneRemovedDevices_StartupCacheOnly 回归:重启后 deviceListCloud 尚为空,
// 只遍历它(旧实现)会漏掉「停机期间在米家被删除」的设备,必须遍历缓存。
func TestPruneRemovedDevices_StartupCacheOnly(t *testing.T) {
c := NewMIoTClient("test", nil, "uid", "cn", CtrlModeCloud)
defer c.Stop()
c.deviceListMu.Lock()
c.deviceListCache["did-keep"] = map[string]interface{}{"did": "did-keep", "home_id": "h1", "online": false}
c.deviceListCache["did-gone"] = map[string]interface{}{"did": "did-gone", "home_id": "h1", "online": false}
removed := c.pruneRemovedDevicesLocked(map[string]map[string]interface{}{
"did-keep": {"did": "did-keep", "home_id": "h1", "online": true},
}, nil)
if len(removed) != 1 || removed[0] != "did-gone" {
t.Fatalf("removed = %v, want [did-gone]", removed)
}
if _, ok := c.deviceListCache["did-gone"]; ok {
t.Error("从 .dict 恢复的已删设备应被剔除")
}
c.deviceListMu.Unlock()
}
// TestPruneRemovedDevices_KeepsDeviceReportedOnlineByGatewayOrLan 网关/局域网仍在线则保留。
func TestPruneRemovedDevices_KeepsDeviceReportedOnlineByGatewayOrLan(t *testing.T) {
for _, tc := range []string{"gateway", "lan"} {
c := NewMIoTClient("test", nil, "uid", "cn", CtrlModeCloud)
c.deviceListMu.Lock()
c.deviceListCache["did-x"] = map[string]interface{}{"did": "did-x", "home_id": "h1", "online": true}
if tc == "gateway" {
c.deviceListGateway["did-x"] = map[string]interface{}{"online": true, "push_available": true}
} else {
c.deviceListLan["did-x"] = map[string]interface{}{"online": true, "push_available": true}
}
removed := c.pruneRemovedDevicesLocked(map[string]map[string]interface{}{
"did-other": {"did": "did-other", "home_id": "h1", "online": true},
}, nil)
if len(removed) != 0 {
t.Errorf("%s 仍在线时不应剔除,removed = %v", tc, removed)
}
if _, ok := c.deviceListCache["did-x"]; !ok {
t.Errorf("%s 仍在线时缓存应保留", tc)
}
c.deviceListMu.Unlock()
c.Stop()
}
}
// TestPruneRemovedDevices_HomeScope 只刷新部分家庭时,不碰其它家庭与无 home 的共享设备。
func TestPruneRemovedDevices_HomeScope(t *testing.T) {
c := NewMIoTClient("test", nil, "uid", "cn", CtrlModeCloud)
defer c.Stop()
c.deviceListMu.Lock()
c.deviceListCache["did-h1"] = map[string]interface{}{"did": "did-h1", "home_id": "h1", "online": true}
c.deviceListCache["did-h2"] = map[string]interface{}{"did": "did-h2", "home_id": "h2", "online": true}
c.deviceListCache["did-shared"] = map[string]interface{}{"did": "did-shared", "home_id": "", "online": true}
// 本次只刷新了 h1:h2 与共享设备不受影响
removed := c.pruneRemovedDevicesLocked(map[string]map[string]interface{}{
"did-h1": {"did": "did-h1", "home_id": "h1", "online": true},
}, map[string]struct{}{"h1": {}})
if len(removed) != 0 {
t.Fatalf("家庭作用域内不应剔除,removed = %v", removed)
}
if _, ok := c.deviceListCache["did-h2"]; !ok {
t.Error("其它家庭的设备不应被剔除")
}
if _, ok := c.deviceListCache["did-shared"]; !ok {
t.Error("无 home_id 的共享设备不应被剔除")
}
// 全量刷新(scope 为空)时,h2 与共享设备都要按云端列表剔除
removed = c.pruneRemovedDevicesLocked(map[string]map[string]interface{}{
"did-h1": {"did": "did-h1", "home_id": "h1", "online": true},
}, nil)
if len(removed) != 2 {
t.Fatalf("全量刷新应剔除 2 台,removed = %v", removed)
}
if _, ok := c.deviceListCache["did-h2"]; ok {
t.Error("did-h2 应被剔除")
}
if _, ok := c.deviceListCache["did-shared"]; ok {
t.Error("did-shared 应被剔除")
}
c.deviceListMu.Unlock()
}
// TestPruneRemovedDevices_KeepsSubDevice 子设备(xxx.s1)被归并到父设备,不参与删除判定。
func TestPruneRemovedDevices_KeepsSubDevice(t *testing.T) {
c := NewMIoTClient("test", nil, "uid", "cn", CtrlModeCloud)
defer c.Stop()
c.deviceListMu.Lock()
c.deviceListCache["gateway1.s1"] = map[string]interface{}{"did": "gateway1.s1", "home_id": "h1", "online": true}
removed := c.pruneRemovedDevicesLocked(map[string]map[string]interface{}{
"gateway1": {"did": "gateway1", "home_id": "h1", "online": true},
}, nil)
if len(removed) != 0 {
t.Fatalf("子设备不应被剔除,removed = %v", removed)
}
if _, ok := c.deviceListCache["gateway1.s1"]; !ok {
t.Error("子设备应保留在缓存中")
}
c.deviceListMu.Unlock()
}
// TestPruneRemovedDevices_EmptyCloudListSkipsRemoval 云端返回空列表视为接口异常,不清库。
func TestPruneRemovedDevices_EmptyCloudListSkipsRemoval(t *testing.T) {
c := NewMIoTClient("test", nil, "uid", "cn", CtrlModeCloud)
defer c.Stop()
c.deviceListMu.Lock()
c.deviceListCache["did1"] = map[string]interface{}{"did": "did1", "home_id": "h1", "online": true}
removed := c.pruneRemovedDevicesLocked(map[string]map[string]interface{}{}, nil)
if len(removed) != 0 {
t.Errorf("空云列表不应剔除任何设备,removed = %v", removed)
}
if _, ok := c.deviceListCache["did1"]; !ok {
t.Error("空云列表时缓存应保持原样")
}
c.deviceListMu.Unlock()
}
// TestRefreshDevices_PrunesDeletedDevice 端到端:打桩云端接口,云端只剩 1 台设备,
// 缓存里的另一台(米家已删除)必须从 DeviceList() 中消失。
func TestRefreshDevices_PrunesDeletedDevice(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case strings.Contains(r.URL.Path, "/homeroom/gethome"):
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"code": float64(0),
"result": map[string]interface{}{
"homelist": []interface{}{
map[string]interface{}{
"id": float64(100), "name": "网吧2", "uid": float64(7),
"roomlist": []interface{}{
map[string]interface{}{
"id": float64(200), "name": "客厅",
"dids": []interface{}{"did-keep"},
},
},
},
},
},
})
case strings.Contains(r.URL.Path, "/home/device_list_page"):
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"code": float64(0),
"result": map[string]interface{}{
"list": []interface{}{
map[string]interface{}{
"did": "did-keep", "name": "保留设备",
"spec_type": "urn:miot-spec-v2:device:plug:0000A001:x:1",
"model": "cuco.plug.v3",
"isOnline": true,
},
},
},
})
default:
http.NotFound(w, r)
}
}))
defer server.Close()
httpCli, err := NewMIoTHttpClient("cn", "cid", "token")
if err != nil {
t.Fatal(err)
}
defer httpCli.Close()
httpCli.baseURL = server.URL
c := NewMIoTClient("test", nil, "uid", "cn", CtrlModeCloud)
defer c.Stop()
c.SetHTTPClient(httpCli)
// 预置缓存(等价于 .dict 恢复):云端已删除的 did-gone 仍在缓存里
c.deviceListMu.Lock()
c.deviceListCache["did-keep"] = map[string]interface{}{"did": "did-keep", "name": "保留设备", "home_id": "100", "online": false}
c.deviceListCache["did-gone"] = map[string]interface{}{"did": "did-gone", "name": "已删设备", "home_id": "100", "online": true}
c.deviceListCloud["did-gone"] = map[string]interface{}{"did": "did-gone", "name": "已删设备", "home_id": "100", "online": true}
c.deviceListMu.Unlock()
if err = c.RefreshDevices(nil); err != nil {
t.Fatalf("RefreshDevices: %v", err)
}
list := c.DeviceList()
if _, ok := list["did-gone"]; ok {
t.Error("DeviceList() 不应再返回米家已删除的设备")
}
if _, ok := list["did-keep"]; !ok {
t.Error("DeviceList() 应保留云端仍存在的设备")
}
}