From 3a91a50dec5c7f59e9112259c358a6583b728066 Mon Sep 17 00:00:00 2001 From: 2995840376 <2995840376@xiaomi.com> Date: Wed, 28 Oct 2026 11:17:10 +0800 Subject: [PATCH] =?UTF-8?q?fix(Q):=20=E4=B8=BB=E5=8A=A8=E6=8E=A7=E5=88=B6?= =?UTF-8?q?=E4=B8=8E=E8=AE=BE=E5=A4=87=E5=9B=9E=E6=89=A7=E9=85=8D=E5=AF=B9?= =?UTF-8?q?=E5=8E=BB=E9=87=8D,=20=E6=B6=88=E9=99=A4APP=E6=93=8D=E4=BD=9C?= =?UTF-8?q?=E4=BA=A7=E7=94=9F=E4=B8=A4=E6=9D=A1=E5=8A=A8=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端 DeviceActivityService.recordEvent 新增配对判定: source=panel/state 的回执, 若 6s 内同设备同物模型属性同动作已有 app/voice/scene 主动控制记录, 则视为同一次操作的 回执并跳过(回执判定前等 1.2s, 给 APP 上报留落库时间) - 生产验证: 仅面板操作保留 1 条 panel; APP 操作+回执只保留 1 条 app - Android 首页新增 30s 动态心跳(页面可见时): 兜底拉取后端侧产生的动态(如面板操作), 只刷动态列表不碰设备列表, 不会造成常用设备跳位 --- .../java/com/xhai/iot/ui/HomeFragment.java | 52 +++++++++++- .../device/service/DeviceActivityService.java | 84 +++++++++++++++++++ 2 files changed, 135 insertions(+), 1 deletion(-) diff --git a/Android/app/src/main/java/com/xhai/iot/ui/HomeFragment.java b/Android/app/src/main/java/com/xhai/iot/ui/HomeFragment.java index a78765f..9c7cc58 100644 --- a/Android/app/src/main/java/com/xhai/iot/ui/HomeFragment.java +++ b/Android/app/src/main/java/com/xhai/iot/ui/HomeFragment.java @@ -111,6 +111,7 @@ public class HomeFragment extends Fragment { if (deviceRefreshHandler != null) { deviceRefreshHandler.removeCallbacks(alignRefreshTask); } + stopHeartbeat(); } @Override @@ -125,18 +126,31 @@ public class HomeFragment extends Fragment { loadDevicesFromBackend(); // 兜底:页面回到前台时同步一次近期动态(覆盖进程内事件丢失的情况) scheduleActivityRefresh(); + // 页面可见:启动动态心跳(兜底拉取后端侧产生的动态,如面板操作) + startHeartbeat(); } } + @Override + public void onPause() { + super.onPause(); + stopHeartbeat(); + } + /** Tab 用 hide/show 切换时不走 onResume,需在此刷新主题色 */ @Override public void onHiddenChanged(boolean hidden) { super.onHiddenChanged(hidden); - if (!hidden && adapter != null && isAdded()) { + if (hidden) { + stopHeartbeat(); + return; + } + if (adapter != null && isAdded()) { adapter.setAccentColor(ThemeManager.getInstance(requireContext()).accent().value); adapter.notifyDataSetChanged(); // 兜底:切回首页时同步一次近期动态 scheduleActivityRefresh(); + startHeartbeat(); } } @@ -164,6 +178,42 @@ public class HomeFragment extends Fragment { activityRefreshHandler.postDelayed(activityRefreshTask, ACTIVITY_REFRESH_DEBOUNCE_MS); } + // ==================== 近期动态心跳 ==================== + + /** + * 心跳间隔:30 秒。 + * + *

用途:面板/物理开关的操作,只有在手机能收到设备 DP 回流时才会被感知 + * (此时事件总线会立即通知,不必等心跳);但当操作发生在手机不在家/无蓝牙连接时, + * 只有后端能感知(涂鸦云消息订阅打通后)。心跳作为兜底,让首页在停留期间 + * 也能把后端侧新产生的动态拉回来。

+ * + *

只刷新动态列表,不触碰设备列表,因此不会造成常用设备跳位。

+ */ + private static final long ACTIVITY_HEARTBEAT_MS = 30_000L; + private final android.os.Handler heartbeatHandler = + new android.os.Handler(android.os.Looper.getMainLooper()); + private final Runnable heartbeatTask = new Runnable() { + @Override + public void run() { + if (isAdded() && getView() != null) { + loadRecentActivities(); + } + heartbeatHandler.postDelayed(this, ACTIVITY_HEARTBEAT_MS); + } + }; + + /** 启动心跳(页面可见时) */ + private void startHeartbeat() { + heartbeatHandler.removeCallbacks(heartbeatTask); + heartbeatHandler.postDelayed(heartbeatTask, ACTIVITY_HEARTBEAT_MS); + } + + /** 停止心跳(页面不可见时) */ + private void stopHeartbeat() { + heartbeatHandler.removeCallbacks(heartbeatTask); + } + // ==================== 刷新节流(避免高频全量重绑导致界面闪动) ==================== /** diff --git a/backend/iot-device/src/main/java/com/iot/device/service/DeviceActivityService.java b/backend/iot-device/src/main/java/com/iot/device/service/DeviceActivityService.java index ff60dc7..284148f 100644 --- a/backend/iot-device/src/main/java/com/iot/device/service/DeviceActivityService.java +++ b/backend/iot-device/src/main/java/com/iot/device/service/DeviceActivityService.java @@ -111,6 +111,16 @@ public class DeviceActivityService { String type = StringUtils.hasText(event.getType()) ? event.getType() : (isControl ? "control" : "state"); + // 1.5) 主动控制 ↔ 回执 配对去重 + // APP 主动控制时先落一条 source=app 的动态,随后设备把同一变化回传上来 + // (source=panel)。二者是同一次操作,用户只应看到一条「APP 操作」。 + // 这里按「同设备 + 同物模型属性 + 同动作 + 时间窗内已有主动控制」判定为回执并跳过。 + if (isPassiveFeedback(event, type) && isPairedWithActiveCommand(device, s)) { + log.debug("回执已与主动控制配对,跳过重复动态: deviceId={} dp={} action={}", + device.getDeviceId(), s.dpCode, s.action); + return; + } + // 2) 去重键 String dedupeKey = computeDedupeKey(event, s); @@ -265,6 +275,80 @@ public class DeviceActivityService { return event.getDeviceId() + ":" + dp + ":" + val + ":" + slot; } + // ==================== 主动控制 ↔ 回执 配对去重 ==================== + + /** 主动控制来源(这些来源算「我方发起的操作」,其回执不应重复展示) */ + private static final java.util.Set ACTIVE_COMMAND_SOURCES = + java.util.Set.of("app", "voice", "scene"); + + /** 回执与主动控制的配对时间窗(毫秒) */ + private static final long FEEDBACK_PAIR_WINDOW_MS = 6_000L; + + /** 回执判定前的等待时间(毫秒):给 APP 侧的主动上报留出落库时间 */ + private static final long FEEDBACK_PAIR_DELAY_MS = 1_200L; + + /** + * 是否是「被动回执」事件(设备把状态变化回传上来)。 + * + *

面板操作、物理开关按下、涂鸦原生面板、云端 DP 上报都归到这里。

+ */ + private boolean isPassiveFeedback(DeviceEvent event, String type) { + if (!"state".equals(type)) { + return false; + } + String source = event.getSource(); + return "panel".equals(source) || "local".equals(source); + } + + /** + * 判断这条回执是否与近期的主动控制是同一次操作。 + * + *

判据:同设备、同物模型属性、同动作(调节类再比值),且时间窗内已存在 + * app/voice/scene 来源的主动控制记录。

+ */ + private boolean isPairedWithActiveCommand(Device device, Semantic s) { + // 回执与 APP 主动上报几乎同时到达,稍等一下再判定,避免"先到先判"漏配对 + try { + Thread.sleep(FEEDBACK_PAIR_DELAY_MS); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + } + try { + java.util.List list = activityMapper.selectList( + new LambdaQueryWrapper() + .eq(DeviceActivityLog::getDeviceId, device.getDeviceId()) + .in(DeviceActivityLog::getSource, ACTIVE_COMMAND_SOURCES) + .ge(DeviceActivityLog::getCreateTime, + java.time.LocalDateTime.now() + .minusSeconds(FEEDBACK_PAIR_WINDOW_MS / 1000)) + .orderByDesc(DeviceActivityLog::getCreateTime) + .last("LIMIT 10")); + for (DeviceActivityLog row : list) { + if (isSameChange(row, s)) { + return true; + } + } + } catch (Exception e) { + log.warn("回执配对查询失败 deviceId={}: {}", device.getDeviceId(), e.getMessage()); + } + return false; + } + + /** 两处记录是否描述了同一个变化(属性一致 + 动作一致) */ + private boolean isSameChange(DeviceActivityLog row, Semantic s) { + if (!java.util.Objects.equals(row.getDpCode(), s.dpCode)) { + return false; + } + if (!java.util.Objects.equals(row.getAction(), s.action)) { + return false; + } + if ("adjust".equals(s.action)) { + // 调节类动作动作名相同但值可能不同(如 调到 30% vs 调到 60%) + return java.util.Objects.equals(row.getValueText(), s.valueText); + } + return true; + } + // ==================== 兼容旧接口 ==================== /**