fix(Q): 主动控制与设备回执配对去重, 消除APP操作产生两条动态

- 后端 DeviceActivityService.recordEvent 新增配对判定: source=panel/state 的回执,
  若 6s 内同设备同物模型属性同动作已有 app/voice/scene 主动控制记录, 则视为同一次操作的
  回执并跳过(回执判定前等 1.2s, 给 APP 上报留落库时间)
- 生产验证: 仅面板操作保留 1 条 panel; APP 操作+回执只保留 1 条 app
- Android 首页新增 30s 动态心跳(页面可见时): 兜底拉取后端侧产生的动态(如面板操作),
  只刷动态列表不碰设备列表, 不会造成常用设备跳位
This commit is contained in:
2995840376 2026-10-28 11:17:10 +08:00
parent f3aaf8fc35
commit 3a91a50dec
2 changed files with 135 additions and 1 deletions

View File

@ -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 秒。
*
* <p>用途:面板/物理开关的操作,只有在手机能收到设备 DP 回流时才会被感知
* (此时事件总线会立即通知,不必等心跳);但当操作发生在手机不在家/无蓝牙连接时,
* 只有后端能感知(涂鸦云消息订阅打通后)。心跳作为兜底,让首页在停留期间
* 也能把后端侧新产生的动态拉回来。</p>
*
* <p>只刷新动态列表,不触碰设备列表,因此不会造成常用设备跳位。</p>
*/
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);
}
// ==================== 刷新节流(避免高频全量重绑导致界面闪动) ====================
/**

View File

@ -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<String> 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;
/**
* 是否是「被动回执」事件(设备把状态变化回传上来)。
*
* <p>面板操作、物理开关按下、涂鸦原生面板、云端 DP 上报都归到这里。</p>
*/
private boolean isPassiveFeedback(DeviceEvent event, String type) {
if (!"state".equals(type)) {
return false;
}
String source = event.getSource();
return "panel".equals(source) || "local".equals(source);
}
/**
* 判断这条回执是否与近期的主动控制是同一次操作。
*
* <p>判据:同设备、同物模型属性、同动作(调节类再比值),且时间窗内已存在
* app/voice/scene 来源的主动控制记录。</p>
*/
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<DeviceActivityLog> list = activityMapper.selectList(
new LambdaQueryWrapper<DeviceActivityLog>()
.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;
}
// ==================== 兼容旧接口 ====================
/**