diff --git a/viewsh-module-iot/viewsh-module-iot-core/src/main/java/com/viewsh/module/iot/core/integration/event/BaseDeviceEvent.java b/viewsh-module-iot/viewsh-module-iot-core/src/main/java/com/viewsh/module/iot/core/integration/event/BaseDeviceEvent.java
index e89b5869..fc513b0c 100644
--- a/viewsh-module-iot/viewsh-module-iot-core/src/main/java/com/viewsh/module/iot/core/integration/event/BaseDeviceEvent.java
+++ b/viewsh-module-iot/viewsh-module-iot-core/src/main/java/com/viewsh/module/iot/core/integration/event/BaseDeviceEvent.java
@@ -1,66 +1,71 @@
-package com.viewsh.module.iot.core.integration.event;
-
-import lombok.AllArgsConstructor;
-import lombok.experimental.SuperBuilder;
-import lombok.Builder;
-import lombok.Data;
-import lombok.NoArgsConstructor;
-
-import java.time.LocalDateTime;
-import java.util.UUID;
-
-/**
- * 跨模块设备事件基类
- *
- * 用于 IntegrationEventBus,发布到 RocketMQ 供其他模块消费
- *
- * @author lzh
- */
-@Data
-@NoArgsConstructor
-@AllArgsConstructor
-@SuperBuilder
-public abstract class BaseDeviceEvent {
-
- /**
- * 事件ID(唯一标识,用于幂等性处理)
- */
- @Builder.Default
- private String eventId = UUID.randomUUID().toString();
-
- /**
- * 设备ID
- */
- private Long deviceId;
-
- /**
- * 设备名称(deviceName)
- */
- private String deviceName;
-
- /**
- * 设备昵称(nickname,用户可读的显示名称)
- */
- private String nickname;
-
- /**
- * 产品ID
- */
- private Long productId;
-
- /**
- * 产品标识符(productKey,用作 RocketMQ Tag)
- */
- private String productKey;
-
- /**
- * 租户ID
- */
- private Long tenantId;
-
- /**
- * 事件时间
- */
- private LocalDateTime eventTime;
-
-}
+package com.viewsh.module.iot.core.integration.event;
+
+import lombok.AllArgsConstructor;
+import lombok.experimental.SuperBuilder;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+import java.util.UUID;
+
+/**
+ * 跨模块设备事件基类
+ *
+ * 用于 IntegrationEventBus,发布到 RocketMQ 供其他模块消费
+ *
+ * @author lzh
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@SuperBuilder
+public abstract class BaseDeviceEvent {
+
+ /**
+ * 事件ID(唯一标识,用于幂等性处理)
+ */
+ @Builder.Default
+ private String eventId = UUID.randomUUID().toString();
+
+ /**
+ * 设备ID
+ */
+ private Long deviceId;
+
+ /**
+ * 设备名称(deviceName)
+ */
+ private String deviceName;
+
+ /**
+ * 设备昵称(nickname,用户可读的显示名称)
+ */
+ private String nickname;
+
+ /**
+ * 产品ID
+ */
+ private Long productId;
+
+ /**
+ * 产品标识符(productKey,用作 RocketMQ Tag)
+ */
+ private String productKey;
+
+ /**
+ * 租户ID
+ */
+ private Long tenantId;
+
+ /**
+ * Project id.
+ */
+ private Long projectId;
+
+ /**
+ * 事件时间
+ */
+ private LocalDateTime eventTime;
+
+}
diff --git a/viewsh-module-iot/viewsh-module-iot-core/src/main/java/com/viewsh/module/iot/core/integration/event/clean/CleanOrderArriveEvent.java b/viewsh-module-iot/viewsh-module-iot-core/src/main/java/com/viewsh/module/iot/core/integration/event/clean/CleanOrderArriveEvent.java
index cc363434..6abcf077 100644
--- a/viewsh-module-iot/viewsh-module-iot-core/src/main/java/com/viewsh/module/iot/core/integration/event/clean/CleanOrderArriveEvent.java
+++ b/viewsh-module-iot/viewsh-module-iot-core/src/main/java/com/viewsh/module/iot/core/integration/event/clean/CleanOrderArriveEvent.java
@@ -1,79 +1,84 @@
-package com.viewsh.module.iot.core.integration.event.clean;
-
-import lombok.AllArgsConstructor;
-import lombok.Builder;
-import lombok.Data;
-import lombok.NoArgsConstructor;
-
-import java.time.LocalDateTime;
-import java.util.Map;
-import java.util.UUID;
-
-/**
- * 保洁工单到岗事件
- *
- * 当工牌检测到蓝牙信标时,IoT 模块发布此事件到 Ops 模块
- * Topic: ops.order.arrive
- *
- * @author AI
- */
-@Data
-@Builder
-@NoArgsConstructor
-@AllArgsConstructor
-public class CleanOrderArriveEvent {
-
- /**
- * 事件ID(唯一标识,用于幂等性处理)
- */
- @Builder.Default
- private String eventId = UUID.randomUUID().toString();
-
- /**
- * 工单类型
- */
- private String orderType;
-
- /**
- * 工单ID(可选,如果已知)
- */
- private Long orderId;
-
- /**
- * 设备ID(工牌)
- */
- private Long deviceId;
-
- /**
- * 设备Key
- */
- private String deviceKey;
-
- /**
- * 区域ID
- */
- private Long areaId;
-
- /**
- * 触发来源
- */
- private String triggerSource;
-
- /**
- * 触发数据(上下文信息)
- *
- * 例如:{beaconMac: "F0:C8:60:1D:10:BB", rssi: -66, windowSnapshot: [-68, -66, -69]}
- */
- private Map triggerData;
-
- /**
- * 事件时间
- */
- @Builder.Default
- private LocalDateTime eventTime = LocalDateTime.now();
-
- /**
- * 租户ID
- */
- private Long tenantId;
-}
+package com.viewsh.module.iot.core.integration.event.clean;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+import java.util.Map;
+import java.util.UUID;
+
+/**
+ * 保洁工单到岗事件
+ *
+ * 当工牌检测到蓝牙信标时,IoT 模块发布此事件到 Ops 模块
+ * Topic: ops.order.arrive
+ *
+ * @author AI
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class CleanOrderArriveEvent {
+
+ /**
+ * 事件ID(唯一标识,用于幂等性处理)
+ */
+ @Builder.Default
+ private String eventId = UUID.randomUUID().toString();
+
+ /**
+ * 工单类型
+ */
+ private String orderType;
+
+ /**
+ * 工单ID(可选,如果已知)
+ */
+ private Long orderId;
+
+ /**
+ * 设备ID(工牌)
+ */
+ private Long deviceId;
+
+ /**
+ * 设备Key
+ */
+ private String deviceKey;
+
+ /**
+ * 区域ID
+ */
+ private Long areaId;
+
+ /**
+ * 触发来源
+ */
+ private String triggerSource;
+
+ /**
+ * 触发数据(上下文信息)
+ *
+ * 例如:{beaconMac: "F0:C8:60:1D:10:BB", rssi: -66, windowSnapshot: [-68, -66, -69]}
+ */
+ private Map triggerData;
+
+ /**
+ * 事件时间
+ */
+ @Builder.Default
+ private LocalDateTime eventTime = LocalDateTime.now();
+
+ /**
+ * 租户ID
+ */
+ private Long tenantId;
+
+ /**
+ * Project id.
+ */
+ private Long projectId;
+}
diff --git a/viewsh-module-iot/viewsh-module-iot-core/src/main/java/com/viewsh/module/iot/core/integration/event/clean/CleanOrderAuditEvent.java b/viewsh-module-iot/viewsh-module-iot-core/src/main/java/com/viewsh/module/iot/core/integration/event/clean/CleanOrderAuditEvent.java
index a153595a..0ce537be 100644
--- a/viewsh-module-iot/viewsh-module-iot-core/src/main/java/com/viewsh/module/iot/core/integration/event/clean/CleanOrderAuditEvent.java
+++ b/viewsh-module-iot/viewsh-module-iot-core/src/main/java/com/viewsh/module/iot/core/integration/event/clean/CleanOrderAuditEvent.java
@@ -1,98 +1,103 @@
-package com.viewsh.module.iot.core.integration.event.clean;
-
-import lombok.AllArgsConstructor;
-import lombok.Builder;
-import lombok.Data;
-import lombok.NoArgsConstructor;
-
-import java.time.LocalDateTime;
-import java.util.Map;
-import java.util.UUID;
-
-/**
- * 保洁工单审计事件
- *
- * 用于记录不改变工单状态但需要记录的关键节点
- * 例如:离岗警告、无效作业拦截
- * Topic: ops.order.audit
- *
- * @author AI
- */
-@Data
-@Builder
-@NoArgsConstructor
-@AllArgsConstructor
-public class CleanOrderAuditEvent {
-
- /**
- * 事件ID(唯一标识,用于幂等性处理)
- */
- @Builder.Default
- private String eventId = UUID.randomUUID().toString();
-
- /**
- * 审计类型
- *
- * BEACON_ARRIVE_CONFIRMED - 信标到岗确认
- * LEAVE_WARNING_SENT - 离岗警告已发送
- * COMPLETE_SUPPRESSED_INVALID - 完成被抑制(作业时长不足)
- * BEACON_COMPLETE_REQUESTED - 信标触发完成请求
- */
- private String auditType;
-
- /**
- * 工单ID(可选)
- */
- private Long orderId;
-
- /**
- * 设备ID(工牌)
- */
- private Long deviceId;
-
- /**
- * 设备Key
- */
- private String deviceKey;
-
- /**
- * 区域ID
- */
- private Long areaId;
-
- /**
- * 保洁员ID(可选)
- */
- private Long cleanerId;
-
- /**
- * 事件级别
- *
- * INFO - 信息
- * WARN - 警告
- * ERROR - 错误
- */
- @Builder.Default
- private String level = "INFO";
-
- /**
- * 审计数据(结构化上下文)
- */
- private Map data;
-
- /**
- * 事件消息(可读描述)
- */
- private String message;
-
- /**
- * 事件时间
- */
- @Builder.Default
- private LocalDateTime eventTime = LocalDateTime.now();
-
- /**
- * 租户ID
- */
- private Long tenantId;
-}
+package com.viewsh.module.iot.core.integration.event.clean;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+import java.util.Map;
+import java.util.UUID;
+
+/**
+ * 保洁工单审计事件
+ *
+ * 用于记录不改变工单状态但需要记录的关键节点
+ * 例如:离岗警告、无效作业拦截
+ * Topic: ops.order.audit
+ *
+ * @author AI
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class CleanOrderAuditEvent {
+
+ /**
+ * 事件ID(唯一标识,用于幂等性处理)
+ */
+ @Builder.Default
+ private String eventId = UUID.randomUUID().toString();
+
+ /**
+ * 审计类型
+ *
+ * BEACON_ARRIVE_CONFIRMED - 信标到岗确认
+ * LEAVE_WARNING_SENT - 离岗警告已发送
+ * COMPLETE_SUPPRESSED_INVALID - 完成被抑制(作业时长不足)
+ * BEACON_COMPLETE_REQUESTED - 信标触发完成请求
+ */
+ private String auditType;
+
+ /**
+ * 工单ID(可选)
+ */
+ private Long orderId;
+
+ /**
+ * 设备ID(工牌)
+ */
+ private Long deviceId;
+
+ /**
+ * 设备Key
+ */
+ private String deviceKey;
+
+ /**
+ * 区域ID
+ */
+ private Long areaId;
+
+ /**
+ * 保洁员ID(可选)
+ */
+ private Long cleanerId;
+
+ /**
+ * 事件级别
+ *
+ * INFO - 信息
+ * WARN - 警告
+ * ERROR - 错误
+ */
+ @Builder.Default
+ private String level = "INFO";
+
+ /**
+ * 审计数据(结构化上下文)
+ */
+ private Map data;
+
+ /**
+ * 事件消息(可读描述)
+ */
+ private String message;
+
+ /**
+ * 事件时间
+ */
+ @Builder.Default
+ private LocalDateTime eventTime = LocalDateTime.now();
+
+ /**
+ * 租户ID
+ */
+ private Long tenantId;
+
+ /**
+ * Project id.
+ */
+ private Long projectId;
+}
diff --git a/viewsh-module-iot/viewsh-module-iot-core/src/main/java/com/viewsh/module/iot/core/integration/event/clean/CleanOrderCompleteEvent.java b/viewsh-module-iot/viewsh-module-iot-core/src/main/java/com/viewsh/module/iot/core/integration/event/clean/CleanOrderCompleteEvent.java
index 8ce377dd..331bd634 100644
--- a/viewsh-module-iot/viewsh-module-iot-core/src/main/java/com/viewsh/module/iot/core/integration/event/clean/CleanOrderCompleteEvent.java
+++ b/viewsh-module-iot/viewsh-module-iot-core/src/main/java/com/viewsh/module/iot/core/integration/event/clean/CleanOrderCompleteEvent.java
@@ -1,79 +1,84 @@
-package com.viewsh.module.iot.core.integration.event.clean;
-
-import lombok.AllArgsConstructor;
-import lombok.Builder;
-import lombok.Data;
-import lombok.NoArgsConstructor;
-
-import java.time.LocalDateTime;
-import java.util.Map;
-import java.util.UUID;
-
-/**
- * 保洁工单完成事件
- *
- * 当工牌信号丢失超时时,IoT 模块发布此事件到 Ops 模块
- * Topic: ops.order.complete
- *
- * @author AI
- */
-@Data
-@Builder
-@NoArgsConstructor
-@AllArgsConstructor
-public class CleanOrderCompleteEvent {
-
- /**
- * 事件ID(唯一标识,用于幂等性处理)
- */
- @Builder.Default
- private String eventId = UUID.randomUUID().toString();
-
- /**
- * 工单类型
- */
- private String orderType;
-
- /**
- * 工单ID
- */
- private Long orderId;
-
- /**
- * 设备ID(工牌)
- */
- private Long deviceId;
-
- /**
- * 设备Key
- */
- private String deviceKey;
-
- /**
- * 区域ID
- */
- private Long areaId;
-
- /**
- * 触发来源
- */
- private String triggerSource;
-
- /**
- * 触发数据(上下文信息)
- *
- * 例如:{durationMs: 780000, lastLossTime: 1736913000000, minValidWorkMinutes: 3}
- */
- private Map triggerData;
-
- /**
- * 事件时间
- */
- @Builder.Default
- private LocalDateTime eventTime = LocalDateTime.now();
-
- /**
- * 租户ID
- */
- private Long tenantId;
-}
+package com.viewsh.module.iot.core.integration.event.clean;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+import java.util.Map;
+import java.util.UUID;
+
+/**
+ * 保洁工单完成事件
+ *
+ * 当工牌信号丢失超时时,IoT 模块发布此事件到 Ops 模块
+ * Topic: ops.order.complete
+ *
+ * @author AI
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class CleanOrderCompleteEvent {
+
+ /**
+ * 事件ID(唯一标识,用于幂等性处理)
+ */
+ @Builder.Default
+ private String eventId = UUID.randomUUID().toString();
+
+ /**
+ * 工单类型
+ */
+ private String orderType;
+
+ /**
+ * 工单ID
+ */
+ private Long orderId;
+
+ /**
+ * 设备ID(工牌)
+ */
+ private Long deviceId;
+
+ /**
+ * 设备Key
+ */
+ private String deviceKey;
+
+ /**
+ * 区域ID
+ */
+ private Long areaId;
+
+ /**
+ * 触发来源
+ */
+ private String triggerSource;
+
+ /**
+ * 触发数据(上下文信息)
+ *
+ * 例如:{durationMs: 780000, lastLossTime: 1736913000000, minValidWorkMinutes: 3}
+ */
+ private Map triggerData;
+
+ /**
+ * 事件时间
+ */
+ @Builder.Default
+ private LocalDateTime eventTime = LocalDateTime.now();
+
+ /**
+ * 租户ID
+ */
+ private Long tenantId;
+
+ /**
+ * Project id.
+ */
+ private Long projectId;
+}
diff --git a/viewsh-module-iot/viewsh-module-iot-core/src/main/java/com/viewsh/module/iot/core/integration/event/clean/CleanOrderCreateEvent.java b/viewsh-module-iot/viewsh-module-iot-core/src/main/java/com/viewsh/module/iot/core/integration/event/clean/CleanOrderCreateEvent.java
index e4e92e8b..8184ec46 100644
--- a/viewsh-module-iot/viewsh-module-iot-core/src/main/java/com/viewsh/module/iot/core/integration/event/clean/CleanOrderCreateEvent.java
+++ b/viewsh-module-iot/viewsh-module-iot-core/src/main/java/com/viewsh/module/iot/core/integration/event/clean/CleanOrderCreateEvent.java
@@ -1,86 +1,91 @@
-package com.viewsh.module.iot.core.integration.event.clean;
-
-import lombok.AllArgsConstructor;
-import lombok.Builder;
-import lombok.Data;
-import lombok.NoArgsConstructor;
-
-import java.time.LocalDateTime;
-import java.util.Map;
-import java.util.UUID;
-
-/**
- * 保洁工单创建事件
- *
- * 当客流阈值触发时,IoT 模块发布此事件到 Ops 模块
- * Topic: ops.order.create
- *
- * @author AI
- */
-@Data
-@Builder
-@NoArgsConstructor
-@AllArgsConstructor
-public class CleanOrderCreateEvent {
-
- /**
- * 事件ID(唯一标识,用于幂等性处理)
- */
- @Builder.Default
- private String eventId = UUID.randomUUID().toString();
-
- /**
- * 工单类型
- */
- private String orderType;
-
- /**
- * 区域ID
- */
- private Long areaId;
-
- /**
- * 触发来源
- *
- * IOT_TRAFFIC - 客流触发
- * IOT_ALERT - 告警触发
- */
- private String triggerSource;
-
- /**
- * 触发设备ID
- */
- private Long triggerDeviceId;
-
- /**
- * 触发设备Key
- */
- private String triggerDeviceKey;
-
- /**
- * 触发数据(上下文信息)
- *
- * 例如:{actualCount: 150, threshold: 100, baseValue: 50}
- */
- private Map triggerData;
-
- /**
- * 工单优先级
- *
- * 0 - P0紧急
- * 1 - P1重要
- * 2 - P2普通
- */
- private Integer priority;
-
- /**
- * 事件时间
- */
- @Builder.Default
- private LocalDateTime eventTime = LocalDateTime.now();
-
- /**
- * 租户ID
- */
- private Long tenantId;
-}
+package com.viewsh.module.iot.core.integration.event.clean;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+import java.util.Map;
+import java.util.UUID;
+
+/**
+ * 保洁工单创建事件
+ *
+ * 当客流阈值触发时,IoT 模块发布此事件到 Ops 模块
+ * Topic: ops.order.create
+ *
+ * @author AI
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class CleanOrderCreateEvent {
+
+ /**
+ * 事件ID(唯一标识,用于幂等性处理)
+ */
+ @Builder.Default
+ private String eventId = UUID.randomUUID().toString();
+
+ /**
+ * 工单类型
+ */
+ private String orderType;
+
+ /**
+ * 区域ID
+ */
+ private Long areaId;
+
+ /**
+ * 触发来源
+ *
+ * IOT_TRAFFIC - 客流触发
+ * IOT_ALERT - 告警触发
+ */
+ private String triggerSource;
+
+ /**
+ * 触发设备ID
+ */
+ private Long triggerDeviceId;
+
+ /**
+ * 触发设备Key
+ */
+ private String triggerDeviceKey;
+
+ /**
+ * 触发数据(上下文信息)
+ *
+ * 例如:{actualCount: 150, threshold: 100, baseValue: 50}
+ */
+ private Map triggerData;
+
+ /**
+ * 工单优先级
+ *
+ * 0 - P0紧急
+ * 1 - P1重要
+ * 2 - P2普通
+ */
+ private Integer priority;
+
+ /**
+ * 事件时间
+ */
+ @Builder.Default
+ private LocalDateTime eventTime = LocalDateTime.now();
+
+ /**
+ * 租户ID
+ */
+ private Long tenantId;
+
+ /**
+ * Project id.
+ */
+ private Long projectId;
+}
diff --git a/viewsh-module-iot/viewsh-module-iot-core/src/main/java/com/viewsh/module/iot/core/integration/event/trajectory/TrajectoryEnterEvent.java b/viewsh-module-iot/viewsh-module-iot-core/src/main/java/com/viewsh/module/iot/core/integration/event/trajectory/TrajectoryEnterEvent.java
index 228baea4..e8b49839 100644
--- a/viewsh-module-iot/viewsh-module-iot-core/src/main/java/com/viewsh/module/iot/core/integration/event/trajectory/TrajectoryEnterEvent.java
+++ b/viewsh-module-iot/viewsh-module-iot-core/src/main/java/com/viewsh/module/iot/core/integration/event/trajectory/TrajectoryEnterEvent.java
@@ -69,4 +69,9 @@ public class TrajectoryEnterEvent {
*/
private Long tenantId;
+ /**
+ * Project id.
+ */
+ private Long projectId;
+
}
diff --git a/viewsh-module-iot/viewsh-module-iot-core/src/main/java/com/viewsh/module/iot/core/integration/event/trajectory/TrajectoryLeaveEvent.java b/viewsh-module-iot/viewsh-module-iot-core/src/main/java/com/viewsh/module/iot/core/integration/event/trajectory/TrajectoryLeaveEvent.java
index e5015617..569a2b22 100644
--- a/viewsh-module-iot/viewsh-module-iot-core/src/main/java/com/viewsh/module/iot/core/integration/event/trajectory/TrajectoryLeaveEvent.java
+++ b/viewsh-module-iot/viewsh-module-iot-core/src/main/java/com/viewsh/module/iot/core/integration/event/trajectory/TrajectoryLeaveEvent.java
@@ -78,4 +78,9 @@ public class TrajectoryLeaveEvent {
*/
private Long tenantId;
+ /**
+ * Project id.
+ */
+ private Long projectId;
+
}
diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/device/IotDeviceServiceImpl.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/device/IotDeviceServiceImpl.java
index 21c42c39..ab3de120 100644
--- a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/device/IotDeviceServiceImpl.java
+++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/device/IotDeviceServiceImpl.java
@@ -1,820 +1,822 @@
-package com.viewsh.module.iot.service.device;
-
-import cn.hutool.core.collection.CollUtil;
-import cn.hutool.core.util.IdUtil;
-import cn.hutool.core.util.ObjUtil;
-import cn.hutool.core.util.StrUtil;
-import cn.hutool.extra.spring.SpringUtil;
-import com.viewsh.framework.common.exception.ServiceException;
-import com.viewsh.framework.common.pojo.PageResult;
-import com.viewsh.framework.common.util.object.BeanUtils;
-import com.viewsh.framework.common.util.validation.ValidationUtils;
-import com.viewsh.framework.tenant.core.aop.TenantIgnore;
-import com.viewsh.framework.tenant.core.context.TenantContextHolder;
-import com.viewsh.framework.tenant.core.util.TenantUtils;
-import com.viewsh.module.iot.core.integration.event.DeviceStatusChangedEvent;
-import com.viewsh.module.iot.controller.admin.device.vo.device.*;
-import com.viewsh.module.iot.core.biz.dto.IotDeviceAuthReqDTO;
-import com.viewsh.module.iot.core.enums.IotAuthTypeEnum;
-import com.viewsh.module.iot.core.enums.IotDeviceStateEnum;
-import com.viewsh.module.iot.core.integration.publisher.IntegrationEventPublisher;
-import com.viewsh.module.iot.core.util.IotDeviceAuthUtils;
-import com.viewsh.module.iot.dal.dataobject.device.IotDeviceDO;
-import com.viewsh.module.iot.dal.dataobject.device.IotDeviceGroupDO;
-import com.viewsh.module.iot.dal.dataobject.product.IotProductDO;
-import com.viewsh.module.iot.dal.dataobject.subsystem.IotSubsystemDO;
-import com.viewsh.framework.mybatis.core.query.LambdaQueryWrapperX;
-import com.viewsh.module.iot.dal.mysql.device.IotDeviceMapper;
-import com.viewsh.module.iot.dal.redis.RedisKeyConstants;
-import com.viewsh.module.iot.dal.redis.subsystem.IotSubsystemDeviceCountRedisDAO;
-import com.viewsh.module.iot.enums.product.IotProductDeviceTypeEnum;
-import com.viewsh.module.iot.service.product.IotProductService;
-import com.viewsh.module.iot.service.subsystem.IotSubsystemService;
-import jakarta.annotation.Resource;
-import jakarta.validation.ConstraintViolationException;
-import lombok.extern.slf4j.Slf4j;
-import org.springframework.cache.annotation.CacheEvict;
-import org.springframework.cache.annotation.Cacheable;
-import org.springframework.cache.annotation.Caching;
-import org.springframework.context.annotation.Lazy;
-import org.springframework.stereotype.Service;
-import org.springframework.transaction.annotation.Transactional;
-import org.springframework.transaction.support.TransactionSynchronization;
-import org.springframework.transaction.support.TransactionSynchronizationManager;
-import org.springframework.validation.annotation.Validated;
-
-import javax.annotation.Nullable;
-import java.time.LocalDateTime;
-import java.util.*;
-
-import static com.viewsh.framework.common.exception.util.ServiceExceptionUtil.exception;
-import static com.viewsh.framework.common.util.collection.CollectionUtils.convertList;
-import static com.viewsh.module.iot.enums.ErrorCodeConstants.*;
-
-/**
- * IoT 设备 Service 实现类
- *
- * @author 芋道源码
- */
-@Service
-@Validated
-@Slf4j
-public class IotDeviceServiceImpl implements IotDeviceService {
-
- @Resource
- private IotDeviceMapper deviceMapper;
-
- @Resource
- @Lazy // 延迟加载,解决循环依赖
- private IotProductService productService;
- @Resource
- @Lazy // 延迟加载,解决循环依赖
- private IotDeviceGroupService deviceGroupService;
- @Resource
- @Lazy // 延迟加载,解决循环依赖(B11)
- private IotSubsystemService subsystemService;
-
- @Resource
- private IotSubsystemDeviceCountRedisDAO subsystemDeviceCountRedisDAO;
-
- @Resource
- private IntegrationEventPublisher integrationEventPublisher;
-
- @Override
- public Long createDevice(IotDeviceSaveReqVO createReqVO) {
- // 1.0 [B11] 校验 subsystemId 必填(新建强制,存量 NULL 兼容)
- if (createReqVO.getSubsystemId() == null) {
- throw exception(DEVICE_SUBSYSTEM_REQUIRED);
- }
- // 1.0.1 校验子系统存在且属于当前租户
- validateSubsystemBelongsToCurrentTenant(createReqVO.getSubsystemId());
-
- // 1.1 校验产品是否存在
- IotProductDO product = productService.getProduct(createReqVO.getProductId());
- if (product == null) {
- throw exception(PRODUCT_NOT_EXISTS);
- }
- // 1.2 统一校验
- validateCreateDeviceParam(product.getProductKey(), createReqVO.getDeviceName(),
- createReqVO.getGatewayId(), product);
- // 1.3 校验分组存在
- deviceGroupService.validateDeviceGroupExists(createReqVO.getGroupIds());
- // 1.4 校验设备序列号全局唯一
- validateSerialNumberUnique(createReqVO.getSerialNumber(), null);
-
- // 2. 插入到数据库
- IotDeviceDO device = BeanUtils.toBean(createReqVO, IotDeviceDO.class);
- initDevice(device, product);
- deviceMapper.insert(device);
-
- // 3. [B11] 同步 Redis 子系统设备计数 +1
- subsystemDeviceCountRedisDAO.incrementCount(device.getTenantId(), createReqVO.getSubsystemId());
-
- return device.getId();
- }
-
- private void validateCreateDeviceParam(String productKey, String deviceName,
- Long gatewayId, IotProductDO product) {
- // 校验设备名称在同一产品下是否唯一
- TenantUtils.executeIgnore(() -> {
- if (deviceMapper.selectByProductKeyAndDeviceName(productKey, deviceName) != null) {
- throw exception(DEVICE_NAME_EXISTS);
- }
- });
- // 校验父设备是否为合法网关
- if (IotProductDeviceTypeEnum.isGatewaySub(product.getDeviceType())
- && gatewayId != null) {
- validateGatewayDeviceExists(gatewayId);
- }
- }
-
- /**
- * 校验设备序列号全局唯一性
- *
- * @param serialNumber 设备序列号
- * @param excludeId 排除的设备编号(用于更新时排除自身)
- */
- private void validateSerialNumberUnique(String serialNumber, Long excludeId) {
- if (StrUtil.isBlank(serialNumber)) {
- return;
- }
- IotDeviceDO existDevice = deviceMapper.selectBySerialNumber(serialNumber);
- if (existDevice != null && ObjUtil.notEqual(existDevice.getId(), excludeId)) {
- throw exception(DEVICE_SERIAL_NUMBER_EXISTS);
- }
- }
-
- private void initDevice(IotDeviceDO device, IotProductDO product) {
- device.setProductId(product.getId()).setProductKey(product.getProductKey())
- .setDeviceType(product.getDeviceType());
- // 生成密钥
- device.setDeviceSecret(generateDeviceSecret());
- // 设置设备状态为未激活
- device.setState(IotDeviceStateEnum.INACTIVE.getState());
- }
-
- @Override
- public void updateDevice(IotDeviceSaveReqVO updateReqVO) {
- updateReqVO.setDeviceName(null).setProductId(null); // 不允许更新
- // 1.1 校验存在
- IotDeviceDO device = validateDeviceExists(updateReqVO.getId());
- // 1.2 校验父设备是否为合法网关
- if (IotProductDeviceTypeEnum.isGatewaySub(device.getDeviceType())
- && updateReqVO.getGatewayId() != null) {
- validateGatewayDeviceExists(updateReqVO.getGatewayId());
- }
- // 1.3 校验分组存在
- deviceGroupService.validateDeviceGroupExists(updateReqVO.getGroupIds());
- // 1.4 校验设备序列号全局唯一
- validateSerialNumberUnique(updateReqVO.getSerialNumber(), updateReqVO.getId());
-
- // 2. 更新到数据库
- IotDeviceDO updateObj = BeanUtils.toBean(updateReqVO, IotDeviceDO.class);
- deviceMapper.updateById(updateObj);
-
- // 3. 清空对应缓存
- deleteDeviceCache(device);
- }
-
- @Override
- @Transactional(rollbackFor = Exception.class)
- public void updateDeviceGroup(IotDeviceUpdateGroupReqVO updateReqVO) {
- // 1.1 校验设备存在
- List devices = deviceMapper.selectByIds(updateReqVO.getIds());
- if (CollUtil.isEmpty(devices)) {
- return;
- }
- // 1.2 校验分组存在
- deviceGroupService.validateDeviceGroupExists(updateReqVO.getGroupIds());
-
- // 3. 更新设备分组
- deviceMapper.updateBatch(convertList(devices, device -> new IotDeviceDO()
- .setId(device.getId()).setGroupIds(updateReqVO.getGroupIds())));
-
- // 4. 清空对应缓存
- deleteDeviceCache(devices);
- }
-
- @Override
- public void deleteDevice(Long id) {
- // 1.1 校验存在
- IotDeviceDO device = validateDeviceExists(id);
- // 1.2 如果是网关设备,检查是否有子设备
- if (device.getGatewayId() != null && deviceMapper.selectCountByGatewayId(id) > 0) {
- throw exception(DEVICE_HAS_CHILDREN);
- }
-
- // 2. 删除设备
- deviceMapper.deleteById(id);
-
- // 3. 清空对应缓存
- deleteDeviceCache(device);
- }
-
- @Override
- @Transactional(rollbackFor = Exception.class)
- public void deleteDeviceList(Collection ids) {
- // 1.1 校验存在
- if (CollUtil.isEmpty(ids)) {
- return;
- }
- List devices = deviceMapper.selectByIds(ids);
- if (CollUtil.isEmpty(devices)) {
- return;
- }
- // 1.2 校验网关设备是否存在
- for (IotDeviceDO device : devices) {
- if (device.getGatewayId() != null && deviceMapper.selectCountByGatewayId(device.getId()) > 0) {
- throw exception(DEVICE_HAS_CHILDREN);
- }
- }
-
- // 2. 删除设备
- deviceMapper.deleteByIds(ids);
-
- // 3. 清空对应缓存
- deleteDeviceCache(devices);
- }
-
- @Override
- public IotDeviceDO validateDeviceExists(Long id) {
- IotDeviceDO device = deviceMapper.selectById(id);
- if (device == null) {
- throw exception(DEVICE_NOT_EXISTS);
- }
- return device;
- }
-
- @Override
- public IotDeviceDO validateDeviceExistsFromCache(Long id) {
- IotDeviceDO device = getSelf().getDeviceFromCache(id);
- if (device == null) {
- throw exception(DEVICE_NOT_EXISTS);
- }
- return device;
- }
-
- /**
- * 校验网关设备是否存在
- *
- * @param id 设备 ID
- */
- private void validateGatewayDeviceExists(Long id) {
- IotDeviceDO device = deviceMapper.selectById(id);
- if (device == null) {
- throw exception(DEVICE_GATEWAY_NOT_EXISTS);
- }
- if (!IotProductDeviceTypeEnum.isGateway(device.getDeviceType())) {
- throw exception(DEVICE_NOT_GATEWAY);
- }
- }
-
- @Override
- public IotDeviceDO getDevice(Long id) {
- return deviceMapper.selectById(id);
- }
-
- @Override
- @Cacheable(value = RedisKeyConstants.DEVICE, key = "#id", unless = "#result == null")
- @TenantIgnore // 忽略租户信息
- public IotDeviceDO getDeviceFromCache(Long id) {
- return deviceMapper.selectById(id);
- }
-
- @Override
- @Cacheable(value = RedisKeyConstants.DEVICE, key = "#productKey + '_' + #deviceName", unless = "#result == null")
- @TenantIgnore // 忽略租户信息,跨租户 productKey + deviceName 是唯一的
- public IotDeviceDO getDeviceFromCache(String productKey, String deviceName) {
- return deviceMapper.selectByProductKeyAndDeviceName(productKey, deviceName);
- }
-
- @Override
- @Cacheable(value = RedisKeyConstants.DEVICE, key = "'deviceName_' + #deviceName", unless = "#result == null")
- @TenantIgnore // 忽略租户信息,用于 JT808 等协议,终端手机号应该是全局唯一的
- public IotDeviceDO getDeviceFromCacheByDeviceName(String deviceName) {
- return deviceMapper.selectByDeviceName(deviceName);
- }
-
- @Override
- public PageResult getDevicePage(IotDevicePageReqVO pageReqVO) {
- return deviceMapper.selectPage(pageReqVO);
- }
-
- @Override
- public List getDeviceListByCondition(@Nullable Integer deviceType, @Nullable Long productId) {
- return deviceMapper.selectListByCondition(deviceType, productId);
- }
-
- @Override
- public List getDeviceListByState(Integer state) {
- return deviceMapper.selectListByState(state);
- }
-
- @Override
- public List getDeviceListByProductId(Long productId) {
- return deviceMapper.selectListByProductId(productId);
- }
-
- @Override
- public void updateDeviceState(IotDeviceDO device, Integer state) {
- // 记录旧状态用于事件发布
- Integer oldState = device.getState();
-
- // 1. 更新状态和时间
- IotDeviceDO updateObj = new IotDeviceDO().setId(device.getId()).setState(state);
- if (device.getOnlineTime() == null
- && Objects.equals(state, IotDeviceStateEnum.ONLINE.getState())) {
- updateObj.setActiveTime(LocalDateTime.now());
- }
- if (Objects.equals(state, IotDeviceStateEnum.ONLINE.getState())) {
- updateObj.setOnlineTime(LocalDateTime.now());
- } else if (Objects.equals(state, IotDeviceStateEnum.OFFLINE.getState())) {
- updateObj.setOfflineTime(LocalDateTime.now());
- }
- deviceMapper.updateById(updateObj);
-
- // 2. 清空对应缓存
- deleteDeviceCache(device);
-
- // 3. 发布状态变更事件到跨模块事件总线
- publishStatusChangedEvent(device, oldState, state);
- }
-
- /**
- * 发布设备状态变更事件到 IntegrationEventBus
- *
- * @param device 设备信息
- * @param oldState 旧状态
- * @param newState 新状态
- */
- private void publishStatusChangedEvent(IotDeviceDO device, Integer oldState, Integer newState) {
- // 只有状态真正发生变化时才发布事件
- if (Objects.equals(oldState, newState)) {
- return;
- }
-
- try {
- // 获取产品信息
- String productKey = "unknown";
- IotProductDO product = productService.getProductFromCache(device.getProductId());
- if (product != null) {
- productKey = product.getProductKey();
- }
-
- // 确定状态变更原因
- String reason = getStateChangeReason(oldState, newState);
-
- DeviceStatusChangedEvent event = DeviceStatusChangedEvent.builder()
- .deviceId(device.getId())
- .deviceName(device.getDeviceName())
- .nickname(device.getNickname())
- .productId(device.getProductId())
- .productKey(productKey)
- .tenantId(device.getTenantId())
- .oldStatus(oldState)
- .newStatus(newState)
- .reason(reason)
- .eventTime(LocalDateTime.now())
- .build();
-
- integrationEventPublisher.publishStatusChanged(event);
- log.debug("[publishStatusChangedEvent] 跨模块状态变更事件已发布: eventId={}, deviceId={}, productKey={}, {} -> {}",
- event.getEventId(), device.getId(), productKey, oldState, newState);
- } catch (Exception e) {
- log.error("[publishStatusChangedEvent] 跨模块状态变更事件发布失败: deviceId={}", device.getId(), e);
- }
- }
-
- /**
- * 根据状态变化获取变更原因
- *
- * @param oldState 旧状态
- * @param newState 新状态
- * @return 变更原因
- */
- private String getStateChangeReason(Integer oldState, Integer newState) {
- if (Objects.equals(newState, IotDeviceStateEnum.ONLINE.getState())) {
- return "设备上线";
- } else if (Objects.equals(newState, IotDeviceStateEnum.OFFLINE.getState())) {
- return "设备离线";
- } else if (Objects.equals(newState, IotDeviceStateEnum.INACTIVE.getState())) {
- return "设备停用";
- }
- return "状态变更";
- }
-
- @Override
- public void updateDeviceState(Long id, Integer state) {
- // 校验存在
- IotDeviceDO device = validateDeviceExists(id);
- // 执行更新
- updateDeviceState(device, state);
- }
-
- @Override
- public Long getDeviceCountByProductId(Long productId) {
- return deviceMapper.selectCountByProductId(productId);
- }
-
- @Override
- public Long getDeviceCountByGroupId(Long groupId) {
- return deviceMapper.selectCountByGroupId(groupId);
- }
-
- /**
- * 生成 deviceSecret
- *
- * @return 生成的 deviceSecret
- */
- private String generateDeviceSecret() {
- return IdUtil.fastSimpleUUID();
- }
-
- // ==================== B11:子系统绑定 ====================
-
- /**
- * 校验子系统存在且属于当前租户(跨租户绑定拒绝)
- *
- * @param subsystemId 子系统 ID
- * @return 子系统 DO
- */
- private IotSubsystemDO validateSubsystemBelongsToCurrentTenant(Long subsystemId) {
- IotSubsystemDO subsystem = subsystemService.getSubsystem(subsystemId);
- if (subsystem == null) {
- throw exception(SUBSYSTEM_NOT_EXISTS);
- }
- Long currentTenantId = TenantContextHolder.getTenantId();
- if (!Objects.equals(subsystem.getTenantId(), currentTenantId)) {
- throw exception(DEVICE_SUBSYSTEM_CROSS_TENANT);
- }
- return subsystem;
- }
-
- @Override
- @Transactional(rollbackFor = Exception.class)
- public void bindDeviceToSubsystem(Long deviceId, Long subsystemId) {
- // 1. 校验设备存在
- IotDeviceDO device = validateDeviceExists(deviceId);
- Long oldSubsystemId = device.getSubsystemId();
-
- // 2. 校验目标子系统存在且同租户
- validateSubsystemBelongsToCurrentTenant(subsystemId);
-
- // 3. 如果已绑定相同子系统,无需操作
- if (Objects.equals(oldSubsystemId, subsystemId)) {
- return;
- }
-
- // 4. 更新设备子系统
- deviceMapper.updateById(new IotDeviceDO().setId(deviceId).setSubsystemId(subsystemId));
-
- // 5. 事务提交后同步 Redis 计数(避免事务回滚导致计数脏)
- Long tenantId = device.getTenantId();
- Long finalOldSubsystemId = oldSubsystemId;
- TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
- @Override
- public void afterCommit() {
- if (finalOldSubsystemId != null) {
- subsystemDeviceCountRedisDAO.decrementCount(tenantId, finalOldSubsystemId);
- }
- subsystemDeviceCountRedisDAO.incrementCount(tenantId, subsystemId);
- }
- });
- }
-
- @Override
- @Transactional(rollbackFor = Exception.class)
- public void batchBindDevicesToSubsystem(Collection deviceIds, Long subsystemId) {
- if (CollUtil.isEmpty(deviceIds)) {
- return;
- }
- // 1. 校验目标子系统存在且同租户
- validateSubsystemBelongsToCurrentTenant(subsystemId);
-
- // 2. 查询所有设备,统计旧子系统变化量
- List devices = deviceMapper.selectByIds(deviceIds);
- if (CollUtil.isEmpty(devices)) {
- return;
- }
- Long tenantId = TenantContextHolder.getTenantId();
-
- // 3. 统计旧子系统的减量(各子系统需减少的设备数)
- Map decrementMap = new HashMap<>();
- for (IotDeviceDO device : devices) {
- Long oldSubId = device.getSubsystemId();
- if (oldSubId != null && !Objects.equals(oldSubId, subsystemId)) {
- decrementMap.merge(oldSubId, 1L, Long::sum);
- }
- }
- // 统计真正需要绑定的设备数(排除已是目标子系统的设备)
- long incrementCount = devices.stream()
- .filter(d -> !Objects.equals(d.getSubsystemId(), subsystemId))
- .count();
-
- // 4. 批量更新 DB
- deviceMapper.updateSubsystemIdByIds(deviceIds, subsystemId);
-
- // 5. 事务提交后批量更新 Redis 计数
- TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
- @Override
- public void afterCommit() {
- // 旧子系统计数各自减少
- decrementMap.forEach((oldSubId, count) -> {
- for (long i = 0; i < count; i++) {
- subsystemDeviceCountRedisDAO.decrementCount(tenantId, oldSubId);
- }
- });
- // 新子系统计数增加
- for (long i = 0; i < incrementCount; i++) {
- subsystemDeviceCountRedisDAO.incrementCount(tenantId, subsystemId);
- }
- }
- });
- }
-
- @Override
- @Transactional(rollbackFor = Exception.class)
- public void unbindDeviceFromSubsystem(Long deviceId) {
- // 1. 校验设备存在
- IotDeviceDO device = validateDeviceExists(deviceId);
- Long oldSubsystemId = device.getSubsystemId();
- if (oldSubsystemId == null) {
- return; // 本来就未绑定,无需操作
- }
-
- // 2. 清空子系统
- deviceMapper.updateById(new IotDeviceDO().setId(deviceId).setSubsystemId(null));
-
- // 3. 事务提交后同步 Redis 计数
- Long tenantId = device.getTenantId();
- TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
- @Override
- public void afterCommit() {
- subsystemDeviceCountRedisDAO.decrementCount(tenantId, oldSubsystemId);
- }
- });
- }
-
- @Override
- public List getUnassignedDevices() {
- return deviceMapper.selectList(new LambdaQueryWrapperX()
- .isNull(IotDeviceDO::getSubsystemId));
- }
-
- @Override
- @Transactional(rollbackFor = Exception.class) // 添加事务,异常则回滚所有导入
- public IotDeviceImportRespVO importDevice(List importDevices, boolean updateSupport) {
- // 1. 参数校验
- if (CollUtil.isEmpty(importDevices)) {
- throw exception(DEVICE_IMPORT_LIST_IS_EMPTY);
- }
-
- // 2. 遍历,逐个创建 or 更新
- IotDeviceImportRespVO respVO = IotDeviceImportRespVO.builder().createDeviceNames(new ArrayList<>())
- .updateDeviceNames(new ArrayList<>()).failureDeviceNames(new LinkedHashMap<>()).build();
- importDevices.forEach(importDevice -> {
- try {
- // 2.1.1 校验字段是否符合要求
- try {
- ValidationUtils.validate(importDevice);
- } catch (ConstraintViolationException ex) {
- respVO.getFailureDeviceNames().put(importDevice.getDeviceName(), ex.getMessage());
- return;
- }
- // 2.1.2 校验产品是否存在
- IotProductDO product = productService.validateProductExists(importDevice.getProductKey());
- // 2.1.3 校验父设备是否存在
- Long gatewayId = null;
- if (StrUtil.isNotEmpty(importDevice.getParentDeviceName())) {
- IotDeviceDO gatewayDevice = deviceMapper.selectByDeviceName(importDevice.getParentDeviceName());
- if (gatewayDevice == null) {
- throw exception(DEVICE_GATEWAY_NOT_EXISTS);
- }
- if (!IotProductDeviceTypeEnum.isGateway(gatewayDevice.getDeviceType())) {
- throw exception(DEVICE_NOT_GATEWAY);
- }
- gatewayId = gatewayDevice.getId();
- }
- // 2.1.4 校验设备分组是否存在
- Set groupIds = new HashSet<>();
- if (StrUtil.isNotEmpty(importDevice.getGroupNames())) {
- String[] groupNames = importDevice.getGroupNames().split(",");
- for (String groupName : groupNames) {
- IotDeviceGroupDO group = deviceGroupService.getDeviceGroupByName(groupName);
- if (group == null) {
- throw exception(DEVICE_GROUP_NOT_EXISTS);
- }
- groupIds.add(group.getId());
- }
- }
-
- // 2.2.1 判断如果不存在,在进行插入
- IotDeviceDO existDevice = deviceMapper.selectByDeviceName(importDevice.getDeviceName());
- if (existDevice == null) {
- createDevice(new IotDeviceSaveReqVO()
- .setDeviceName(importDevice.getDeviceName())
- .setProductId(product.getId()).setGatewayId(gatewayId).setGroupIds(groupIds)
- .setLocationType(importDevice.getLocationType()));
- respVO.getCreateDeviceNames().add(importDevice.getDeviceName());
- return;
- }
- // 2.2.2 如果存在,判断是否允许更新
- if (updateSupport) {
- throw exception(DEVICE_KEY_EXISTS);
- }
- updateDevice(new IotDeviceSaveReqVO().setId(existDevice.getId())
- .setGatewayId(gatewayId).setGroupIds(groupIds).setLocationType(importDevice.getLocationType()));
- respVO.getUpdateDeviceNames().add(importDevice.getDeviceName());
- } catch (ServiceException ex) {
- respVO.getFailureDeviceNames().put(importDevice.getDeviceName(), ex.getMessage());
- }
- });
- return respVO;
- }
-
- @Override
- public IotDeviceAuthInfoRespVO getDeviceAuthInfo(Long id) {
- IotDeviceDO device = validateDeviceExists(id);
-
- // 获取生效的认证类型
- String effectiveAuthType = device.getAuthType();
- String targetSecret = device.getDeviceSecret();
-
- if (StrUtil.isEmpty(effectiveAuthType)) {
- IotProductDO product = productService.getProductFromCache(device.getProductId());
- if (product != null) {
- effectiveAuthType = product.getAuthType();
- // 如果是产品级一型一密,需要获取 ProductSecret
- if (IotAuthTypeEnum.PRODUCT_SECRET.getType().equals(effectiveAuthType)) {
- targetSecret = product.getProductSecret();
- }
- }
- } else if (IotAuthTypeEnum.PRODUCT_SECRET.getType().equals(effectiveAuthType)) {
- // 如果设备级强制设为一型一密(罕见但逻辑上兼容)
- IotProductDO product = productService.getProductFromCache(device.getProductId());
- if (product != null) {
- targetSecret = product.getProductSecret();
- }
- }
-
- // 使用 IotDeviceAuthUtils 生成认证信息
- IotDeviceAuthUtils.AuthInfo authInfo = IotDeviceAuthUtils.getAuthInfo(
- device.getProductKey(), device.getDeviceName(), targetSecret);
- return BeanUtils.toBean(authInfo, IotDeviceAuthInfoRespVO.class);
- }
-
- private void deleteDeviceCache(IotDeviceDO device) {
- // 保证 Spring AOP 触发
- getSelf().deleteDeviceCache0(device);
- }
-
- private void deleteDeviceCache(List devices) {
- devices.forEach(this::deleteDeviceCache);
- }
-
- @SuppressWarnings("unused")
- @Caching(evict = {
- @CacheEvict(value = RedisKeyConstants.DEVICE, key = "#device.id"),
- @CacheEvict(value = RedisKeyConstants.DEVICE, key = "#device.productKey + '_' + #device.deviceName")
- })
- public void deleteDeviceCache0(IotDeviceDO device) {
- }
-
- @Override
- public Long getDeviceCount(LocalDateTime createTime) {
- return deviceMapper.selectCountByCreateTime(createTime);
- }
-
- @Override
- public Map getDeviceCountMapByProductId() {
- return deviceMapper.selectDeviceCountMapByProductId();
- }
-
- @Override
- public Map getDeviceCountMapByState() {
- return deviceMapper.selectDeviceCountGroupByState();
- }
-
- @Override
- public List getDeviceListByProductKeyAndNames(String productKey, List deviceNames) {
- if (StrUtil.isBlank(productKey) || CollUtil.isEmpty(deviceNames)) {
- return Collections.emptyList();
- }
- return deviceMapper.selectByProductKeyAndDeviceNames(productKey, deviceNames);
- }
-
- @Override
- public boolean authDevice(IotDeviceAuthReqDTO authReqDTO) {
- // 1. 校验设备是否存在
- IotDeviceAuthUtils.DeviceInfo deviceInfo = IotDeviceAuthUtils.parseUsername(authReqDTO.getUsername());
- if (deviceInfo == null) {
- log.error("[authDevice][认证失败,username({}) 格式不正确]", authReqDTO.getUsername());
- return false;
- }
- String deviceName = deviceInfo.getDeviceName();
- String productKey = deviceInfo.getProductKey();
- IotDeviceDO device = getSelf().getDeviceFromCache(productKey, deviceName);
-
- // 1.1 处理动态注册 (Dynamic Registration)
- if (device == null) {
- // 获取产品信息
- IotProductDO product = productService.getProductByProductKey(productKey);
- if (product != null && IotAuthTypeEnum.DYNAMIC.getType().equals(product.getAuthType())) {
- // 自动创建设备
- IotDeviceSaveReqVO createReq = new IotDeviceSaveReqVO()
- .setProductId(product.getId())
- .setDeviceName(deviceName);
- try {
- // TODO: 考虑并发问题,这里简单处理
- getSelf().createDevice(createReq);
- // 重新获取设备
- device = getSelf().getDeviceFromCache(productKey, deviceName);
- log.info("[authDevice][动态注册设备成功: {}/{}]", productKey, deviceName);
- } catch (Exception e) {
- log.error("[authDevice][动态注册设备失败: {}/{}]", productKey, deviceName, e);
- return false;
- }
- }
- }
-
- if (device == null) {
- log.warn("[authDevice][设备({}/{}) 不存在]", productKey, deviceName);
- return false;
- }
-
- // 2. 校验密码
- // 2.1 获取生效的认证类型
- String effectiveAuthType = device.getAuthType();
- if (StrUtil.isEmpty(effectiveAuthType)) {
- IotProductDO product = productService.getProductFromCache(device.getProductId());
- if (product != null) {
- effectiveAuthType = product.getAuthType();
- }
- }
-
- // 2.2 根据类型校验
- String targetSecret = device.getDeviceSecret(); // 默认为一机一密
-
- if (IotAuthTypeEnum.PRODUCT_SECRET.getType().equals(effectiveAuthType)) {
- // 一型一密:使用 ProductSecret
- IotProductDO product = productService.getProductFromCache(device.getProductId());
- if (product == null || StrUtil.isEmpty(product.getProductSecret())) {
- log.error("[authDevice][一型一密认证失败,ProductSecret 为空: {}]", productKey);
- return false;
- }
- targetSecret = product.getProductSecret();
- } else if (IotAuthTypeEnum.NONE.getType().equals(effectiveAuthType)) {
- // 免鉴权:直接通过
- return true;
- } else if (IotAuthTypeEnum.DYNAMIC.getType().equals(effectiveAuthType)) {
- // 动态注册后,通常转为一机一密或一型一密,这里假设动态注册使用一型一密校验
- // 或者是免鉴权。具体策略视业务而定。这里暂定为一型一密。
- IotProductDO product = productService.getProductFromCache(device.getProductId());
- if (product != null) {
- targetSecret = product.getProductSecret();
- }
- }
-
- IotDeviceAuthUtils.AuthInfo authInfo = IotDeviceAuthUtils.getAuthInfo(productKey, deviceName, targetSecret);
- if (ObjUtil.notEqual(authInfo.getPassword(), authReqDTO.getPassword())) {
- log.error("[authDevice][设备({}/{}) 密码不正确]", productKey, deviceName);
- return false;
- }
- return true;
- }
-
- @Override
- public List validateDeviceListExists(Collection ids) {
- List devices = getDeviceList(ids);
- if (devices.size() != ids.size()) {
- throw exception(DEVICE_NOT_EXISTS);
- }
- return devices;
- }
-
- @Override
- public List getDeviceList(Collection ids) {
- if (CollUtil.isEmpty(ids)) {
- return Collections.emptyList();
- }
- return deviceMapper.selectByIds(ids);
- }
-
- @Override
- public void updateDeviceFirmware(Long deviceId, Long firmwareId) {
- // 1. 校验设备是否存在
- IotDeviceDO device = validateDeviceExists(deviceId);
-
- // 2. 更新设备固件版本
- IotDeviceDO updateObj = new IotDeviceDO().setId(deviceId).setFirmwareId(firmwareId);
- deviceMapper.updateById(updateObj);
-
- // 3. 清空对应缓存
- deleteDeviceCache(device);
- }
-
- private IotDeviceServiceImpl getSelf() {
- return SpringUtil.getBean(getClass());
- }
-
-}
+package com.viewsh.module.iot.service.device;
+
+import cn.hutool.core.collection.CollUtil;
+import cn.hutool.core.util.IdUtil;
+import cn.hutool.core.util.ObjUtil;
+import cn.hutool.core.util.StrUtil;
+import cn.hutool.extra.spring.SpringUtil;
+import com.viewsh.framework.common.exception.ServiceException;
+import com.viewsh.framework.common.pojo.PageResult;
+import com.viewsh.framework.common.util.object.BeanUtils;
+import com.viewsh.framework.common.util.validation.ValidationUtils;
+import com.viewsh.framework.tenant.core.aop.TenantIgnore;
+import com.viewsh.framework.tenant.core.context.ProjectContextHolder;
+import com.viewsh.framework.tenant.core.context.TenantContextHolder;
+import com.viewsh.framework.tenant.core.util.TenantUtils;
+import com.viewsh.module.iot.core.integration.event.DeviceStatusChangedEvent;
+import com.viewsh.module.iot.controller.admin.device.vo.device.*;
+import com.viewsh.module.iot.core.biz.dto.IotDeviceAuthReqDTO;
+import com.viewsh.module.iot.core.enums.IotAuthTypeEnum;
+import com.viewsh.module.iot.core.enums.IotDeviceStateEnum;
+import com.viewsh.module.iot.core.integration.publisher.IntegrationEventPublisher;
+import com.viewsh.module.iot.core.util.IotDeviceAuthUtils;
+import com.viewsh.module.iot.dal.dataobject.device.IotDeviceDO;
+import com.viewsh.module.iot.dal.dataobject.device.IotDeviceGroupDO;
+import com.viewsh.module.iot.dal.dataobject.product.IotProductDO;
+import com.viewsh.module.iot.dal.dataobject.subsystem.IotSubsystemDO;
+import com.viewsh.framework.mybatis.core.query.LambdaQueryWrapperX;
+import com.viewsh.module.iot.dal.mysql.device.IotDeviceMapper;
+import com.viewsh.module.iot.dal.redis.RedisKeyConstants;
+import com.viewsh.module.iot.dal.redis.subsystem.IotSubsystemDeviceCountRedisDAO;
+import com.viewsh.module.iot.enums.product.IotProductDeviceTypeEnum;
+import com.viewsh.module.iot.service.product.IotProductService;
+import com.viewsh.module.iot.service.subsystem.IotSubsystemService;
+import jakarta.annotation.Resource;
+import jakarta.validation.ConstraintViolationException;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.cache.annotation.CacheEvict;
+import org.springframework.cache.annotation.Cacheable;
+import org.springframework.cache.annotation.Caching;
+import org.springframework.context.annotation.Lazy;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.transaction.support.TransactionSynchronization;
+import org.springframework.transaction.support.TransactionSynchronizationManager;
+import org.springframework.validation.annotation.Validated;
+
+import javax.annotation.Nullable;
+import java.time.LocalDateTime;
+import java.util.*;
+
+import static com.viewsh.framework.common.exception.util.ServiceExceptionUtil.exception;
+import static com.viewsh.framework.common.util.collection.CollectionUtils.convertList;
+import static com.viewsh.module.iot.enums.ErrorCodeConstants.*;
+
+/**
+ * IoT 设备 Service 实现类
+ *
+ * @author 芋道源码
+ */
+@Service
+@Validated
+@Slf4j
+public class IotDeviceServiceImpl implements IotDeviceService {
+
+ @Resource
+ private IotDeviceMapper deviceMapper;
+
+ @Resource
+ @Lazy // 延迟加载,解决循环依赖
+ private IotProductService productService;
+ @Resource
+ @Lazy // 延迟加载,解决循环依赖
+ private IotDeviceGroupService deviceGroupService;
+ @Resource
+ @Lazy // 延迟加载,解决循环依赖(B11)
+ private IotSubsystemService subsystemService;
+
+ @Resource
+ private IotSubsystemDeviceCountRedisDAO subsystemDeviceCountRedisDAO;
+
+ @Resource
+ private IntegrationEventPublisher integrationEventPublisher;
+
+ @Override
+ public Long createDevice(IotDeviceSaveReqVO createReqVO) {
+ // 1.0 [B11] 校验 subsystemId 必填(新建强制,存量 NULL 兼容)
+ if (createReqVO.getSubsystemId() == null) {
+ throw exception(DEVICE_SUBSYSTEM_REQUIRED);
+ }
+ // 1.0.1 校验子系统存在且属于当前租户
+ validateSubsystemBelongsToCurrentTenant(createReqVO.getSubsystemId());
+
+ // 1.1 校验产品是否存在
+ IotProductDO product = productService.getProduct(createReqVO.getProductId());
+ if (product == null) {
+ throw exception(PRODUCT_NOT_EXISTS);
+ }
+ // 1.2 统一校验
+ validateCreateDeviceParam(product.getProductKey(), createReqVO.getDeviceName(),
+ createReqVO.getGatewayId(), product);
+ // 1.3 校验分组存在
+ deviceGroupService.validateDeviceGroupExists(createReqVO.getGroupIds());
+ // 1.4 校验设备序列号全局唯一
+ validateSerialNumberUnique(createReqVO.getSerialNumber(), null);
+
+ // 2. 插入到数据库
+ IotDeviceDO device = BeanUtils.toBean(createReqVO, IotDeviceDO.class);
+ initDevice(device, product);
+ deviceMapper.insert(device);
+
+ // 3. [B11] 同步 Redis 子系统设备计数 +1
+ subsystemDeviceCountRedisDAO.incrementCount(device.getTenantId(), createReqVO.getSubsystemId());
+
+ return device.getId();
+ }
+
+ private void validateCreateDeviceParam(String productKey, String deviceName,
+ Long gatewayId, IotProductDO product) {
+ // 校验设备名称在同一产品下是否唯一
+ TenantUtils.executeIgnore(() -> {
+ if (deviceMapper.selectByProductKeyAndDeviceName(productKey, deviceName) != null) {
+ throw exception(DEVICE_NAME_EXISTS);
+ }
+ });
+ // 校验父设备是否为合法网关
+ if (IotProductDeviceTypeEnum.isGatewaySub(product.getDeviceType())
+ && gatewayId != null) {
+ validateGatewayDeviceExists(gatewayId);
+ }
+ }
+
+ /**
+ * 校验设备序列号全局唯一性
+ *
+ * @param serialNumber 设备序列号
+ * @param excludeId 排除的设备编号(用于更新时排除自身)
+ */
+ private void validateSerialNumberUnique(String serialNumber, Long excludeId) {
+ if (StrUtil.isBlank(serialNumber)) {
+ return;
+ }
+ IotDeviceDO existDevice = deviceMapper.selectBySerialNumber(serialNumber);
+ if (existDevice != null && ObjUtil.notEqual(existDevice.getId(), excludeId)) {
+ throw exception(DEVICE_SERIAL_NUMBER_EXISTS);
+ }
+ }
+
+ private void initDevice(IotDeviceDO device, IotProductDO product) {
+ device.setProductId(product.getId()).setProductKey(product.getProductKey())
+ .setDeviceType(product.getDeviceType());
+ // 生成密钥
+ device.setDeviceSecret(generateDeviceSecret());
+ // 设置设备状态为未激活
+ device.setState(IotDeviceStateEnum.INACTIVE.getState());
+ }
+
+ @Override
+ public void updateDevice(IotDeviceSaveReqVO updateReqVO) {
+ updateReqVO.setDeviceName(null).setProductId(null); // 不允许更新
+ // 1.1 校验存在
+ IotDeviceDO device = validateDeviceExists(updateReqVO.getId());
+ // 1.2 校验父设备是否为合法网关
+ if (IotProductDeviceTypeEnum.isGatewaySub(device.getDeviceType())
+ && updateReqVO.getGatewayId() != null) {
+ validateGatewayDeviceExists(updateReqVO.getGatewayId());
+ }
+ // 1.3 校验分组存在
+ deviceGroupService.validateDeviceGroupExists(updateReqVO.getGroupIds());
+ // 1.4 校验设备序列号全局唯一
+ validateSerialNumberUnique(updateReqVO.getSerialNumber(), updateReqVO.getId());
+
+ // 2. 更新到数据库
+ IotDeviceDO updateObj = BeanUtils.toBean(updateReqVO, IotDeviceDO.class);
+ deviceMapper.updateById(updateObj);
+
+ // 3. 清空对应缓存
+ deleteDeviceCache(device);
+ }
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public void updateDeviceGroup(IotDeviceUpdateGroupReqVO updateReqVO) {
+ // 1.1 校验设备存在
+ List devices = deviceMapper.selectByIds(updateReqVO.getIds());
+ if (CollUtil.isEmpty(devices)) {
+ return;
+ }
+ // 1.2 校验分组存在
+ deviceGroupService.validateDeviceGroupExists(updateReqVO.getGroupIds());
+
+ // 3. 更新设备分组
+ deviceMapper.updateBatch(convertList(devices, device -> new IotDeviceDO()
+ .setId(device.getId()).setGroupIds(updateReqVO.getGroupIds())));
+
+ // 4. 清空对应缓存
+ deleteDeviceCache(devices);
+ }
+
+ @Override
+ public void deleteDevice(Long id) {
+ // 1.1 校验存在
+ IotDeviceDO device = validateDeviceExists(id);
+ // 1.2 如果是网关设备,检查是否有子设备
+ if (device.getGatewayId() != null && deviceMapper.selectCountByGatewayId(id) > 0) {
+ throw exception(DEVICE_HAS_CHILDREN);
+ }
+
+ // 2. 删除设备
+ deviceMapper.deleteById(id);
+
+ // 3. 清空对应缓存
+ deleteDeviceCache(device);
+ }
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public void deleteDeviceList(Collection ids) {
+ // 1.1 校验存在
+ if (CollUtil.isEmpty(ids)) {
+ return;
+ }
+ List devices = deviceMapper.selectByIds(ids);
+ if (CollUtil.isEmpty(devices)) {
+ return;
+ }
+ // 1.2 校验网关设备是否存在
+ for (IotDeviceDO device : devices) {
+ if (device.getGatewayId() != null && deviceMapper.selectCountByGatewayId(device.getId()) > 0) {
+ throw exception(DEVICE_HAS_CHILDREN);
+ }
+ }
+
+ // 2. 删除设备
+ deviceMapper.deleteByIds(ids);
+
+ // 3. 清空对应缓存
+ deleteDeviceCache(devices);
+ }
+
+ @Override
+ public IotDeviceDO validateDeviceExists(Long id) {
+ IotDeviceDO device = deviceMapper.selectById(id);
+ if (device == null) {
+ throw exception(DEVICE_NOT_EXISTS);
+ }
+ return device;
+ }
+
+ @Override
+ public IotDeviceDO validateDeviceExistsFromCache(Long id) {
+ IotDeviceDO device = getSelf().getDeviceFromCache(id);
+ if (device == null) {
+ throw exception(DEVICE_NOT_EXISTS);
+ }
+ return device;
+ }
+
+ /**
+ * 校验网关设备是否存在
+ *
+ * @param id 设备 ID
+ */
+ private void validateGatewayDeviceExists(Long id) {
+ IotDeviceDO device = deviceMapper.selectById(id);
+ if (device == null) {
+ throw exception(DEVICE_GATEWAY_NOT_EXISTS);
+ }
+ if (!IotProductDeviceTypeEnum.isGateway(device.getDeviceType())) {
+ throw exception(DEVICE_NOT_GATEWAY);
+ }
+ }
+
+ @Override
+ public IotDeviceDO getDevice(Long id) {
+ return deviceMapper.selectById(id);
+ }
+
+ @Override
+ @Cacheable(value = RedisKeyConstants.DEVICE, key = "#id", unless = "#result == null")
+ @TenantIgnore // 忽略租户信息
+ public IotDeviceDO getDeviceFromCache(Long id) {
+ return deviceMapper.selectById(id);
+ }
+
+ @Override
+ @Cacheable(value = RedisKeyConstants.DEVICE, key = "#productKey + '_' + #deviceName", unless = "#result == null")
+ @TenantIgnore // 忽略租户信息,跨租户 productKey + deviceName 是唯一的
+ public IotDeviceDO getDeviceFromCache(String productKey, String deviceName) {
+ return deviceMapper.selectByProductKeyAndDeviceName(productKey, deviceName);
+ }
+
+ @Override
+ @Cacheable(value = RedisKeyConstants.DEVICE, key = "'deviceName_' + #deviceName", unless = "#result == null")
+ @TenantIgnore // 忽略租户信息,用于 JT808 等协议,终端手机号应该是全局唯一的
+ public IotDeviceDO getDeviceFromCacheByDeviceName(String deviceName) {
+ return deviceMapper.selectByDeviceName(deviceName);
+ }
+
+ @Override
+ public PageResult getDevicePage(IotDevicePageReqVO pageReqVO) {
+ return deviceMapper.selectPage(pageReqVO);
+ }
+
+ @Override
+ public List getDeviceListByCondition(@Nullable Integer deviceType, @Nullable Long productId) {
+ return deviceMapper.selectListByCondition(deviceType, productId);
+ }
+
+ @Override
+ public List getDeviceListByState(Integer state) {
+ return deviceMapper.selectListByState(state);
+ }
+
+ @Override
+ public List getDeviceListByProductId(Long productId) {
+ return deviceMapper.selectListByProductId(productId);
+ }
+
+ @Override
+ public void updateDeviceState(IotDeviceDO device, Integer state) {
+ // 记录旧状态用于事件发布
+ Integer oldState = device.getState();
+
+ // 1. 更新状态和时间
+ IotDeviceDO updateObj = new IotDeviceDO().setId(device.getId()).setState(state);
+ if (device.getOnlineTime() == null
+ && Objects.equals(state, IotDeviceStateEnum.ONLINE.getState())) {
+ updateObj.setActiveTime(LocalDateTime.now());
+ }
+ if (Objects.equals(state, IotDeviceStateEnum.ONLINE.getState())) {
+ updateObj.setOnlineTime(LocalDateTime.now());
+ } else if (Objects.equals(state, IotDeviceStateEnum.OFFLINE.getState())) {
+ updateObj.setOfflineTime(LocalDateTime.now());
+ }
+ deviceMapper.updateById(updateObj);
+
+ // 2. 清空对应缓存
+ deleteDeviceCache(device);
+
+ // 3. 发布状态变更事件到跨模块事件总线
+ publishStatusChangedEvent(device, oldState, state);
+ }
+
+ /**
+ * 发布设备状态变更事件到 IntegrationEventBus
+ *
+ * @param device 设备信息
+ * @param oldState 旧状态
+ * @param newState 新状态
+ */
+ private void publishStatusChangedEvent(IotDeviceDO device, Integer oldState, Integer newState) {
+ // 只有状态真正发生变化时才发布事件
+ if (Objects.equals(oldState, newState)) {
+ return;
+ }
+
+ try {
+ // 获取产品信息
+ String productKey = "unknown";
+ IotProductDO product = productService.getProductFromCache(device.getProductId());
+ if (product != null) {
+ productKey = product.getProductKey();
+ }
+
+ // 确定状态变更原因
+ String reason = getStateChangeReason(oldState, newState);
+
+ DeviceStatusChangedEvent event = DeviceStatusChangedEvent.builder()
+ .deviceId(device.getId())
+ .deviceName(device.getDeviceName())
+ .nickname(device.getNickname())
+ .productId(device.getProductId())
+ .productKey(productKey)
+ .tenantId(device.getTenantId())
+ .projectId(device.getProjectId() != null ? device.getProjectId() : ProjectContextHolder.getProjectId())
+ .oldStatus(oldState)
+ .newStatus(newState)
+ .reason(reason)
+ .eventTime(LocalDateTime.now())
+ .build();
+
+ integrationEventPublisher.publishStatusChanged(event);
+ log.debug("[publishStatusChangedEvent] 跨模块状态变更事件已发布: eventId={}, deviceId={}, productKey={}, {} -> {}",
+ event.getEventId(), device.getId(), productKey, oldState, newState);
+ } catch (Exception e) {
+ log.error("[publishStatusChangedEvent] 跨模块状态变更事件发布失败: deviceId={}", device.getId(), e);
+ }
+ }
+
+ /**
+ * 根据状态变化获取变更原因
+ *
+ * @param oldState 旧状态
+ * @param newState 新状态
+ * @return 变更原因
+ */
+ private String getStateChangeReason(Integer oldState, Integer newState) {
+ if (Objects.equals(newState, IotDeviceStateEnum.ONLINE.getState())) {
+ return "设备上线";
+ } else if (Objects.equals(newState, IotDeviceStateEnum.OFFLINE.getState())) {
+ return "设备离线";
+ } else if (Objects.equals(newState, IotDeviceStateEnum.INACTIVE.getState())) {
+ return "设备停用";
+ }
+ return "状态变更";
+ }
+
+ @Override
+ public void updateDeviceState(Long id, Integer state) {
+ // 校验存在
+ IotDeviceDO device = validateDeviceExists(id);
+ // 执行更新
+ updateDeviceState(device, state);
+ }
+
+ @Override
+ public Long getDeviceCountByProductId(Long productId) {
+ return deviceMapper.selectCountByProductId(productId);
+ }
+
+ @Override
+ public Long getDeviceCountByGroupId(Long groupId) {
+ return deviceMapper.selectCountByGroupId(groupId);
+ }
+
+ /**
+ * 生成 deviceSecret
+ *
+ * @return 生成的 deviceSecret
+ */
+ private String generateDeviceSecret() {
+ return IdUtil.fastSimpleUUID();
+ }
+
+ // ==================== B11:子系统绑定 ====================
+
+ /**
+ * 校验子系统存在且属于当前租户(跨租户绑定拒绝)
+ *
+ * @param subsystemId 子系统 ID
+ * @return 子系统 DO
+ */
+ private IotSubsystemDO validateSubsystemBelongsToCurrentTenant(Long subsystemId) {
+ IotSubsystemDO subsystem = subsystemService.getSubsystem(subsystemId);
+ if (subsystem == null) {
+ throw exception(SUBSYSTEM_NOT_EXISTS);
+ }
+ Long currentTenantId = TenantContextHolder.getTenantId();
+ if (!Objects.equals(subsystem.getTenantId(), currentTenantId)) {
+ throw exception(DEVICE_SUBSYSTEM_CROSS_TENANT);
+ }
+ return subsystem;
+ }
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public void bindDeviceToSubsystem(Long deviceId, Long subsystemId) {
+ // 1. 校验设备存在
+ IotDeviceDO device = validateDeviceExists(deviceId);
+ Long oldSubsystemId = device.getSubsystemId();
+
+ // 2. 校验目标子系统存在且同租户
+ validateSubsystemBelongsToCurrentTenant(subsystemId);
+
+ // 3. 如果已绑定相同子系统,无需操作
+ if (Objects.equals(oldSubsystemId, subsystemId)) {
+ return;
+ }
+
+ // 4. 更新设备子系统
+ deviceMapper.updateById(new IotDeviceDO().setId(deviceId).setSubsystemId(subsystemId));
+
+ // 5. 事务提交后同步 Redis 计数(避免事务回滚导致计数脏)
+ Long tenantId = device.getTenantId();
+ Long finalOldSubsystemId = oldSubsystemId;
+ TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
+ @Override
+ public void afterCommit() {
+ if (finalOldSubsystemId != null) {
+ subsystemDeviceCountRedisDAO.decrementCount(tenantId, finalOldSubsystemId);
+ }
+ subsystemDeviceCountRedisDAO.incrementCount(tenantId, subsystemId);
+ }
+ });
+ }
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public void batchBindDevicesToSubsystem(Collection deviceIds, Long subsystemId) {
+ if (CollUtil.isEmpty(deviceIds)) {
+ return;
+ }
+ // 1. 校验目标子系统存在且同租户
+ validateSubsystemBelongsToCurrentTenant(subsystemId);
+
+ // 2. 查询所有设备,统计旧子系统变化量
+ List devices = deviceMapper.selectByIds(deviceIds);
+ if (CollUtil.isEmpty(devices)) {
+ return;
+ }
+ Long tenantId = TenantContextHolder.getTenantId();
+
+ // 3. 统计旧子系统的减量(各子系统需减少的设备数)
+ Map decrementMap = new HashMap<>();
+ for (IotDeviceDO device : devices) {
+ Long oldSubId = device.getSubsystemId();
+ if (oldSubId != null && !Objects.equals(oldSubId, subsystemId)) {
+ decrementMap.merge(oldSubId, 1L, Long::sum);
+ }
+ }
+ // 统计真正需要绑定的设备数(排除已是目标子系统的设备)
+ long incrementCount = devices.stream()
+ .filter(d -> !Objects.equals(d.getSubsystemId(), subsystemId))
+ .count();
+
+ // 4. 批量更新 DB
+ deviceMapper.updateSubsystemIdByIds(deviceIds, subsystemId);
+
+ // 5. 事务提交后批量更新 Redis 计数
+ TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
+ @Override
+ public void afterCommit() {
+ // 旧子系统计数各自减少
+ decrementMap.forEach((oldSubId, count) -> {
+ for (long i = 0; i < count; i++) {
+ subsystemDeviceCountRedisDAO.decrementCount(tenantId, oldSubId);
+ }
+ });
+ // 新子系统计数增加
+ for (long i = 0; i < incrementCount; i++) {
+ subsystemDeviceCountRedisDAO.incrementCount(tenantId, subsystemId);
+ }
+ }
+ });
+ }
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public void unbindDeviceFromSubsystem(Long deviceId) {
+ // 1. 校验设备存在
+ IotDeviceDO device = validateDeviceExists(deviceId);
+ Long oldSubsystemId = device.getSubsystemId();
+ if (oldSubsystemId == null) {
+ return; // 本来就未绑定,无需操作
+ }
+
+ // 2. 清空子系统
+ deviceMapper.updateById(new IotDeviceDO().setId(deviceId).setSubsystemId(null));
+
+ // 3. 事务提交后同步 Redis 计数
+ Long tenantId = device.getTenantId();
+ TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
+ @Override
+ public void afterCommit() {
+ subsystemDeviceCountRedisDAO.decrementCount(tenantId, oldSubsystemId);
+ }
+ });
+ }
+
+ @Override
+ public List getUnassignedDevices() {
+ return deviceMapper.selectList(new LambdaQueryWrapperX()
+ .isNull(IotDeviceDO::getSubsystemId));
+ }
+
+ @Override
+ @Transactional(rollbackFor = Exception.class) // 添加事务,异常则回滚所有导入
+ public IotDeviceImportRespVO importDevice(List importDevices, boolean updateSupport) {
+ // 1. 参数校验
+ if (CollUtil.isEmpty(importDevices)) {
+ throw exception(DEVICE_IMPORT_LIST_IS_EMPTY);
+ }
+
+ // 2. 遍历,逐个创建 or 更新
+ IotDeviceImportRespVO respVO = IotDeviceImportRespVO.builder().createDeviceNames(new ArrayList<>())
+ .updateDeviceNames(new ArrayList<>()).failureDeviceNames(new LinkedHashMap<>()).build();
+ importDevices.forEach(importDevice -> {
+ try {
+ // 2.1.1 校验字段是否符合要求
+ try {
+ ValidationUtils.validate(importDevice);
+ } catch (ConstraintViolationException ex) {
+ respVO.getFailureDeviceNames().put(importDevice.getDeviceName(), ex.getMessage());
+ return;
+ }
+ // 2.1.2 校验产品是否存在
+ IotProductDO product = productService.validateProductExists(importDevice.getProductKey());
+ // 2.1.3 校验父设备是否存在
+ Long gatewayId = null;
+ if (StrUtil.isNotEmpty(importDevice.getParentDeviceName())) {
+ IotDeviceDO gatewayDevice = deviceMapper.selectByDeviceName(importDevice.getParentDeviceName());
+ if (gatewayDevice == null) {
+ throw exception(DEVICE_GATEWAY_NOT_EXISTS);
+ }
+ if (!IotProductDeviceTypeEnum.isGateway(gatewayDevice.getDeviceType())) {
+ throw exception(DEVICE_NOT_GATEWAY);
+ }
+ gatewayId = gatewayDevice.getId();
+ }
+ // 2.1.4 校验设备分组是否存在
+ Set groupIds = new HashSet<>();
+ if (StrUtil.isNotEmpty(importDevice.getGroupNames())) {
+ String[] groupNames = importDevice.getGroupNames().split(",");
+ for (String groupName : groupNames) {
+ IotDeviceGroupDO group = deviceGroupService.getDeviceGroupByName(groupName);
+ if (group == null) {
+ throw exception(DEVICE_GROUP_NOT_EXISTS);
+ }
+ groupIds.add(group.getId());
+ }
+ }
+
+ // 2.2.1 判断如果不存在,在进行插入
+ IotDeviceDO existDevice = deviceMapper.selectByDeviceName(importDevice.getDeviceName());
+ if (existDevice == null) {
+ createDevice(new IotDeviceSaveReqVO()
+ .setDeviceName(importDevice.getDeviceName())
+ .setProductId(product.getId()).setGatewayId(gatewayId).setGroupIds(groupIds)
+ .setLocationType(importDevice.getLocationType()));
+ respVO.getCreateDeviceNames().add(importDevice.getDeviceName());
+ return;
+ }
+ // 2.2.2 如果存在,判断是否允许更新
+ if (updateSupport) {
+ throw exception(DEVICE_KEY_EXISTS);
+ }
+ updateDevice(new IotDeviceSaveReqVO().setId(existDevice.getId())
+ .setGatewayId(gatewayId).setGroupIds(groupIds).setLocationType(importDevice.getLocationType()));
+ respVO.getUpdateDeviceNames().add(importDevice.getDeviceName());
+ } catch (ServiceException ex) {
+ respVO.getFailureDeviceNames().put(importDevice.getDeviceName(), ex.getMessage());
+ }
+ });
+ return respVO;
+ }
+
+ @Override
+ public IotDeviceAuthInfoRespVO getDeviceAuthInfo(Long id) {
+ IotDeviceDO device = validateDeviceExists(id);
+
+ // 获取生效的认证类型
+ String effectiveAuthType = device.getAuthType();
+ String targetSecret = device.getDeviceSecret();
+
+ if (StrUtil.isEmpty(effectiveAuthType)) {
+ IotProductDO product = productService.getProductFromCache(device.getProductId());
+ if (product != null) {
+ effectiveAuthType = product.getAuthType();
+ // 如果是产品级一型一密,需要获取 ProductSecret
+ if (IotAuthTypeEnum.PRODUCT_SECRET.getType().equals(effectiveAuthType)) {
+ targetSecret = product.getProductSecret();
+ }
+ }
+ } else if (IotAuthTypeEnum.PRODUCT_SECRET.getType().equals(effectiveAuthType)) {
+ // 如果设备级强制设为一型一密(罕见但逻辑上兼容)
+ IotProductDO product = productService.getProductFromCache(device.getProductId());
+ if (product != null) {
+ targetSecret = product.getProductSecret();
+ }
+ }
+
+ // 使用 IotDeviceAuthUtils 生成认证信息
+ IotDeviceAuthUtils.AuthInfo authInfo = IotDeviceAuthUtils.getAuthInfo(
+ device.getProductKey(), device.getDeviceName(), targetSecret);
+ return BeanUtils.toBean(authInfo, IotDeviceAuthInfoRespVO.class);
+ }
+
+ private void deleteDeviceCache(IotDeviceDO device) {
+ // 保证 Spring AOP 触发
+ getSelf().deleteDeviceCache0(device);
+ }
+
+ private void deleteDeviceCache(List devices) {
+ devices.forEach(this::deleteDeviceCache);
+ }
+
+ @SuppressWarnings("unused")
+ @Caching(evict = {
+ @CacheEvict(value = RedisKeyConstants.DEVICE, key = "#device.id"),
+ @CacheEvict(value = RedisKeyConstants.DEVICE, key = "#device.productKey + '_' + #device.deviceName")
+ })
+ public void deleteDeviceCache0(IotDeviceDO device) {
+ }
+
+ @Override
+ public Long getDeviceCount(LocalDateTime createTime) {
+ return deviceMapper.selectCountByCreateTime(createTime);
+ }
+
+ @Override
+ public Map getDeviceCountMapByProductId() {
+ return deviceMapper.selectDeviceCountMapByProductId();
+ }
+
+ @Override
+ public Map getDeviceCountMapByState() {
+ return deviceMapper.selectDeviceCountGroupByState();
+ }
+
+ @Override
+ public List getDeviceListByProductKeyAndNames(String productKey, List deviceNames) {
+ if (StrUtil.isBlank(productKey) || CollUtil.isEmpty(deviceNames)) {
+ return Collections.emptyList();
+ }
+ return deviceMapper.selectByProductKeyAndDeviceNames(productKey, deviceNames);
+ }
+
+ @Override
+ public boolean authDevice(IotDeviceAuthReqDTO authReqDTO) {
+ // 1. 校验设备是否存在
+ IotDeviceAuthUtils.DeviceInfo deviceInfo = IotDeviceAuthUtils.parseUsername(authReqDTO.getUsername());
+ if (deviceInfo == null) {
+ log.error("[authDevice][认证失败,username({}) 格式不正确]", authReqDTO.getUsername());
+ return false;
+ }
+ String deviceName = deviceInfo.getDeviceName();
+ String productKey = deviceInfo.getProductKey();
+ IotDeviceDO device = getSelf().getDeviceFromCache(productKey, deviceName);
+
+ // 1.1 处理动态注册 (Dynamic Registration)
+ if (device == null) {
+ // 获取产品信息
+ IotProductDO product = productService.getProductByProductKey(productKey);
+ if (product != null && IotAuthTypeEnum.DYNAMIC.getType().equals(product.getAuthType())) {
+ // 自动创建设备
+ IotDeviceSaveReqVO createReq = new IotDeviceSaveReqVO()
+ .setProductId(product.getId())
+ .setDeviceName(deviceName);
+ try {
+ // TODO: 考虑并发问题,这里简单处理
+ getSelf().createDevice(createReq);
+ // 重新获取设备
+ device = getSelf().getDeviceFromCache(productKey, deviceName);
+ log.info("[authDevice][动态注册设备成功: {}/{}]", productKey, deviceName);
+ } catch (Exception e) {
+ log.error("[authDevice][动态注册设备失败: {}/{}]", productKey, deviceName, e);
+ return false;
+ }
+ }
+ }
+
+ if (device == null) {
+ log.warn("[authDevice][设备({}/{}) 不存在]", productKey, deviceName);
+ return false;
+ }
+
+ // 2. 校验密码
+ // 2.1 获取生效的认证类型
+ String effectiveAuthType = device.getAuthType();
+ if (StrUtil.isEmpty(effectiveAuthType)) {
+ IotProductDO product = productService.getProductFromCache(device.getProductId());
+ if (product != null) {
+ effectiveAuthType = product.getAuthType();
+ }
+ }
+
+ // 2.2 根据类型校验
+ String targetSecret = device.getDeviceSecret(); // 默认为一机一密
+
+ if (IotAuthTypeEnum.PRODUCT_SECRET.getType().equals(effectiveAuthType)) {
+ // 一型一密:使用 ProductSecret
+ IotProductDO product = productService.getProductFromCache(device.getProductId());
+ if (product == null || StrUtil.isEmpty(product.getProductSecret())) {
+ log.error("[authDevice][一型一密认证失败,ProductSecret 为空: {}]", productKey);
+ return false;
+ }
+ targetSecret = product.getProductSecret();
+ } else if (IotAuthTypeEnum.NONE.getType().equals(effectiveAuthType)) {
+ // 免鉴权:直接通过
+ return true;
+ } else if (IotAuthTypeEnum.DYNAMIC.getType().equals(effectiveAuthType)) {
+ // 动态注册后,通常转为一机一密或一型一密,这里假设动态注册使用一型一密校验
+ // 或者是免鉴权。具体策略视业务而定。这里暂定为一型一密。
+ IotProductDO product = productService.getProductFromCache(device.getProductId());
+ if (product != null) {
+ targetSecret = product.getProductSecret();
+ }
+ }
+
+ IotDeviceAuthUtils.AuthInfo authInfo = IotDeviceAuthUtils.getAuthInfo(productKey, deviceName, targetSecret);
+ if (ObjUtil.notEqual(authInfo.getPassword(), authReqDTO.getPassword())) {
+ log.error("[authDevice][设备({}/{}) 密码不正确]", productKey, deviceName);
+ return false;
+ }
+ return true;
+ }
+
+ @Override
+ public List validateDeviceListExists(Collection ids) {
+ List devices = getDeviceList(ids);
+ if (devices.size() != ids.size()) {
+ throw exception(DEVICE_NOT_EXISTS);
+ }
+ return devices;
+ }
+
+ @Override
+ public List getDeviceList(Collection ids) {
+ if (CollUtil.isEmpty(ids)) {
+ return Collections.emptyList();
+ }
+ return deviceMapper.selectByIds(ids);
+ }
+
+ @Override
+ public void updateDeviceFirmware(Long deviceId, Long firmwareId) {
+ // 1. 校验设备是否存在
+ IotDeviceDO device = validateDeviceExists(deviceId);
+
+ // 2. 更新设备固件版本
+ IotDeviceDO updateObj = new IotDeviceDO().setId(deviceId).setFirmwareId(firmwareId);
+ deviceMapper.updateById(updateObj);
+
+ // 3. 清空对应缓存
+ deleteDeviceCache(device);
+ }
+
+ private IotDeviceServiceImpl getSelf() {
+ return SpringUtil.getBean(getClass());
+ }
+
+}
diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/rule/clean/processor/BeaconDetectionRuleProcessor.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/rule/clean/processor/BeaconDetectionRuleProcessor.java
index 54bd0fd2..c074e64c 100644
--- a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/rule/clean/processor/BeaconDetectionRuleProcessor.java
+++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/rule/clean/processor/BeaconDetectionRuleProcessor.java
@@ -1,376 +1,379 @@
-package com.viewsh.module.iot.service.rule.clean.processor;
-
-import com.viewsh.framework.tenant.core.context.TenantContextHolder;
-import com.viewsh.module.iot.core.integration.constants.CleanOrderTopics;
-import com.viewsh.module.iot.core.integration.event.clean.CleanOrderArriveEvent;
-import com.viewsh.module.iot.core.integration.event.clean.CleanOrderAuditEvent;
-import com.viewsh.module.iot.dal.dataobject.integration.clean.BeaconPresenceConfig;
-import com.viewsh.module.iot.dal.redis.clean.BadgeDeviceStatusRedisDAO;
-import com.viewsh.module.iot.dal.redis.clean.BeaconArrivedTimeRedisDAO;
-import com.viewsh.module.iot.dal.redis.clean.BeaconRssiWindowRedisDAO;
-import com.viewsh.module.iot.dal.redis.clean.SignalLossRedisDAO;
-import com.viewsh.module.iot.service.integration.clean.CleanOrderIntegrationConfigService;
-import com.viewsh.module.iot.service.rule.clean.detector.RssiSlidingWindowDetector;
-import jakarta.annotation.Resource;
-import lombok.extern.slf4j.Slf4j;
-import org.apache.rocketmq.spring.core.RocketMQTemplate;
-import org.springframework.messaging.support.MessageBuilder;
-import org.springframework.stereotype.Component;
-
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.concurrent.ConcurrentHashMap;
-
-/**
- * 蓝牙信标检测规则处理器
- *
- * 监听工牌的蓝牙属性上报,基于滑动窗口算法检测保洁员到岗/离岗
- * 采用"强进弱出"双阈值,避免信号抖动
- *
- * @author AI
- */
-@Component
-@Slf4j
-public class BeaconDetectionRuleProcessor {
-
- @Resource
- private BeaconRssiWindowRedisDAO windowRedisDAO;
-
- @Resource
- private BeaconArrivedTimeRedisDAO arrivedTimeRedisDAO;
-
- @Resource
- private SignalLossRedisDAO signalLossRedisDAO;
-
- @Resource
- private BadgeDeviceStatusRedisDAO badgeDeviceStatusRedisDAO;
-
- @Resource
- private CleanOrderIntegrationConfigService configService;
-
- @Resource
- private RssiSlidingWindowDetector detector;
-
- @Resource
- private RocketMQTemplate rocketMQTemplate;
-
- /**
- * 上次检测的工单ID缓存(设备ID -> 工单ID)
- * 用于检测工单切换,清理旧工单的检测状态
- */
- private final Map lastDetectedOrderCache = new ConcurrentHashMap<>();
-
- /**
- * 处理蓝牙属性上报
- *
- * 在设备属性上报处理流程中调用此方法
- *
- * @param deviceId 设备ID
- * @param identifier 属性标识符(bluetoothDevices)
- * @param propertyValue 属性值(蓝牙设备数组)
- */
- public void processPropertyChange(Long deviceId, String identifier, Object propertyValue) {
- // 1. 检查是否是蓝牙属性
- if (!"bluetoothDevices".equals(identifier)) {
- return;
- }
-
- log.debug("[BeaconDetection] 收到蓝牙属性:deviceId={}", deviceId);
-
- // 2. 先获取当前工单状态(从中获取正确的 areaId)
- BadgeDeviceStatusRedisDAO.OrderInfo currentOrder = badgeDeviceStatusRedisDAO.getCurrentOrder(deviceId);
-
- if (currentOrder == null || currentOrder.getAreaId() == null) {
- log.debug("[BeaconDetection] 无当前工单,跳过检测:deviceId={}", deviceId);
- // 无工单时清理本地缓存
- lastDetectedOrderCache.remove(deviceId);
- return;
- }
-
- Long areaId = currentOrder.getAreaId();
- Long orderId = currentOrder.getOrderId();
-
- // 3. 检测工单切换,清理旧工单的检测状态
- Long lastOrderId = lastDetectedOrderCache.get(deviceId);
- if (lastOrderId != null && !lastOrderId.equals(orderId)) {
- log.warn("[BeaconDetection] 检测到工单切换,清理旧工单的检测状态: " +
- "deviceId={}, oldOrderId={}, newOrderId={}", deviceId, lastOrderId, orderId);
- // 清理旧的检测状态(清理当前设备的所有区域检测状态)
- cleanupAllDetectionState(deviceId);
- }
- // 更新缓存
- lastDetectedOrderCache.put(deviceId, orderId);
-
- log.debug("[BeaconDetection] 从工单状态获取区域:deviceId={}, areaId={}, orderId={}",
- deviceId, areaId, orderId);
-
- // 3. 获取该区域的信标配置(从 BEACON 类型的设备获取)
- CleanOrderIntegrationConfigService.AreaDeviceConfigWrapper beaconConfigWrapper = configService
- .getConfigByAreaIdAndRelationType(areaId, "BEACON");
-
- if (beaconConfigWrapper == null || beaconConfigWrapper.getConfig() == null) {
- log.debug("[BeaconDetection] 区域无信标配置:areaId={}", areaId);
- return;
- }
-
- BeaconPresenceConfig beaconConfig = beaconConfigWrapper.getConfig().getBeaconPresence();
- if (beaconConfig == null || !beaconConfig.getEnabled()) {
- log.debug("[BeaconDetection] 未启用信标检测:areaId={}", areaId);
- return;
- }
-
- // 4. 解析蓝牙数据,提取目标信标的 RSSI
- Integer targetRssi = detector.extractTargetRssi(propertyValue, beaconConfig);
-
- log.debug("[BeaconDetection] 提取RSSI:deviceId={}, areaId={}, beaconMac={}, rssi={}",
- deviceId, areaId, beaconConfig.getBeaconMac(), targetRssi);
-
- // 5. 更新滑动窗口(使用 enter 和 exit 中较大的窗口大小)
- int maxWindowSize = Math.max(
- beaconConfig.getEnter().getWindowSize(),
- beaconConfig.getExit().getWindowSize());
- windowRedisDAO.updateWindow(deviceId, areaId, targetRssi, maxWindowSize);
-
- // 6. 获取当前窗口样本
- List window = windowRedisDAO.getWindow(deviceId, areaId);
-
- // 7. 确定当前状态
- RssiSlidingWindowDetector.AreaState currentState = determineState(currentOrder, areaId, deviceId);
-
- // 8. 执行检测
- RssiSlidingWindowDetector.DetectionResult result = detector.detect(
- window,
- beaconConfig.getEnter(),
- beaconConfig.getExit(),
- currentState);
-
- // 9. 处理检测结果
- switch (result) {
- case ARRIVE_CONFIRMED:
- handleArriveConfirmed(deviceId, areaId, window, beaconConfig, currentOrder);
- break;
- case LEAVE_CONFIRMED:
- handleLeaveConfirmed(deviceId, areaId, window, beaconConfig);
- break;
- default:
- // NO_CHANGE,不做处理
- break;
- }
- }
-
- /**
- * 处理到达确认
- */
- private void handleArriveConfirmed(Long deviceId, Long areaId, List window,
- BeaconPresenceConfig beaconConfig,
- BadgeDeviceStatusRedisDAO.OrderInfo currentOrder) {
- log.info("[BeaconDetection] 到达确认:deviceId={}, areaId={}, window={}",
- deviceId, areaId, window);
-
- // 1. 记录到达时间
- arrivedTimeRedisDAO.recordArrivedTime(deviceId, areaId, System.currentTimeMillis());
-
- // 2. 清除离岗记录(如果存在)
- signalLossRedisDAO.clearLossRecord(deviceId, areaId);
-
- // 3. 清理 RSSI 窗口(避免历史脏数据影响新的在岗周期)
- windowRedisDAO.clearWindow(deviceId, areaId);
- log.debug("[BeaconDetection] 到岗时清理RSSI窗口:deviceId={}, areaId={}", deviceId, areaId);
-
- // 4. 获取当前最新的 RSSI 值(使用原窗口快照,因为已清理)
- Integer currentRssi = window.isEmpty() ? -999 : window.get(window.size() - 1);
-
- // 5. 构建触发数据
- Map triggerData = new HashMap<>();
- triggerData.put("beaconMac", beaconConfig.getBeaconMac());
- triggerData.put("rssi", currentRssi);
- triggerData.put("windowSnapshot", window);
- triggerData.put("enterRssiThreshold", beaconConfig.getEnter().getRssiThreshold());
-
- // 6. 发布到岗事件
- if (beaconConfig.getEnter().getAutoArrival()) {
- publishArriveEvent(deviceId, currentOrder.getOrderId(), areaId, triggerData);
- }
-
- // 7. 发布审计日志
- publishAuditEvent("BEACON_ARRIVE_CONFIRMED", deviceId, null, areaId, currentOrder.getOrderId(),
- "蓝牙信标自动到岗确认", triggerData);
- }
-
- /**
- * 处理离开确认
- */
- private void handleLeaveConfirmed(Long deviceId, Long areaId, List window,
- BeaconPresenceConfig beaconConfig) {
- log.info("[BeaconDetection] 离开确认:deviceId={}, areaId={}, window={}",
- deviceId, areaId, window);
-
- // 注意:离岗警告阶段不清除arrivedTime,保持IN_AREA状态
- // arrivedTime在工单完成时由SignalLossRuleProcessor.cleanupRedisData清除
-
- // P0 插队校验:检查当前工单是否属于正在检查的区域
- if (isSwitchingOrder(deviceId, areaId)) {
- log.debug("[BeaconDetection][P0Interrupt] 检测到工单切换,跳过区域 {} 的离岗处理",
- areaId);
- // 清理该区域的离岗记录(避免内存泄漏)
- signalLossRedisDAO.clearLossRecord(deviceId, areaId);
- return;
- }
-
- BeaconPresenceConfig.ExitConfig exitConfig = beaconConfig.getExit();
-
- // 1. 检查是否是首次丢失
- Long firstLossTime = signalLossRedisDAO.getFirstLossTime(deviceId, areaId);
-
- if (firstLossTime == null) {
- // 首次丢失
- signalLossRedisDAO.recordFirstLoss(deviceId, areaId, System.currentTimeMillis());
-
- // 2. 发布审计日志
- Map data = new HashMap<>();
- data.put("firstLossTime", System.currentTimeMillis());
- data.put("rssi", window.isEmpty() ? -999 : window.get(window.size() - 1));
- data.put("warningDelayMinutes", exitConfig.getWarningDelayMinutes());
-
- // 获取当前工单ID
- BadgeDeviceStatusRedisDAO.OrderInfo currentOrder = badgeDeviceStatusRedisDAO.getCurrentOrder(deviceId);
- Long orderId = currentOrder != null ? currentOrder.getOrderId() : null;
-
- publishAuditEvent("BEACON_LEAVE_WARNING_SENT", deviceId, null, areaId, orderId,
- "保洁员离开作业区域,已发送警告", data);
- } else {
- // 4. 更新最后丢失时间
- signalLossRedisDAO.updateLastLossTime(deviceId, areaId, System.currentTimeMillis());
-
- log.debug("[BeaconDetection] 更新最后丢失时间:deviceId={}, areaId={}", deviceId, areaId);
- }
- }
-
- /**
- * 发布到岗事件
- */
- private void publishArriveEvent(Long deviceId, Long orderId, Long areaId, Map triggerData) {
- try {
- CleanOrderArriveEvent event = CleanOrderArriveEvent.builder()
- .tenantId(TenantContextHolder.getTenantId())
- .eventId(java.util.UUID.randomUUID().toString())
- .orderType("CLEAN")
- .orderId(orderId)
- .deviceId(deviceId)
- .areaId(areaId)
- .triggerSource("IOT_BEACON")
- .triggerData(triggerData)
- .build();
-
- rocketMQTemplate.syncSend(CleanOrderTopics.ORDER_ARRIVE, MessageBuilder.withPayload(event).build());
-
- log.info("[BeaconDetection] 发布到岗事件:eventId={}, deviceId={}, areaId={}, orderId={}",
- event.getEventId(), deviceId, areaId, orderId);
- } catch (Exception e) {
- log.error("[BeaconDetection] 发布到岗事件失败:deviceId={}, areaId={}", deviceId, areaId, e);
- }
- }
-
- /**
- * 发布审计事件
- */
- private void publishAuditEvent(String auditType, Long deviceId, String deviceKey,
- Long areaId, Long orderId, String message, Map data) {
- try {
- CleanOrderAuditEvent event = CleanOrderAuditEvent.builder()
- .tenantId(TenantContextHolder.getTenantId())
- .eventId(java.util.UUID.randomUUID().toString())
- .auditType(auditType)
- .deviceId(deviceId)
- .deviceKey(deviceKey)
- .areaId(areaId)
- .orderId(orderId)
- .message(message)
- .data(data)
- .build();
-
- rocketMQTemplate.syncSend(CleanOrderTopics.ORDER_AUDIT, MessageBuilder.withPayload(event).build());
-
- log.debug("[BeaconDetection] 发布审计事件:auditType={}, deviceId={}, areaId={}, orderId={}",
- auditType, deviceId, areaId, orderId);
- } catch (Exception e) {
- log.error("[BeaconDetection] 发布审计事件失败:auditType={}, deviceId={}", auditType, deviceId, e);
- }
- }
-
- /**
- * 发布 TTS 事件(通过审计事件传递)
- */
- private void publishTtsEvent(Long deviceId, String text) {
- Map data = new HashMap<>();
- data.put("tts", text);
- data.put("timestamp", System.currentTimeMillis());
-
- publishAuditEvent("TTS_REQUEST", deviceId, null, null, null, text, data);
- }
-
- /**
- * 确定当前状态
- */
- private RssiSlidingWindowDetector.AreaState determineState(
- BadgeDeviceStatusRedisDAO.OrderInfo currentOrder, Long areaId, Long deviceId) {
-
- // 优先检查IoT本地到岗记录(避免依赖跨模块异步同步)
- Long arrivedTime = arrivedTimeRedisDAO.getArrivedTime(deviceId, areaId);
- if (arrivedTime != null) {
- log.debug("[BeaconDetection] 本地状态:已到岗, deviceId={}, areaId={}, arrivedTime={}",
- deviceId, areaId, arrivedTime);
- return RssiSlidingWindowDetector.AreaState.IN_AREA;
- }
-
- // 降级:检查Ops模块的工单状态(向后兼容)
- if (currentOrder != null
- && "ARRIVED".equals(currentOrder.getStatus())
- && areaId.equals(currentOrder.getAreaId())) {
- // 同步本地状态(修复历史数据)
- arrivedTimeRedisDAO.recordArrivedTime(deviceId, areaId, System.currentTimeMillis());
- log.info("[BeaconDetection] 从Ops状态同步本地到岗记录:deviceId={}, areaId={}",
- deviceId, areaId);
- return RssiSlidingWindowDetector.AreaState.IN_AREA;
- }
-
- return RssiSlidingWindowDetector.AreaState.OUT_AREA;
- }
-
- /**
- * 检查是否正在切换工单(P0 插队场景)
- *
- * 如果当前工单的区域ID与正在检查的区域不一致,说明保洁员已切换到其他区域的工单
- *
- * @param deviceId 设备ID
- * @param areaId 正在检查的区域ID
- * @return true-工单切换场景,false-正常离岗场景
- */
- private boolean isSwitchingOrder(Long deviceId, Long areaId) {
- BadgeDeviceStatusRedisDAO.OrderInfo currentOrder = badgeDeviceStatusRedisDAO.getCurrentOrder(deviceId);
- return currentOrder != null && !currentOrder.getAreaId().equals(areaId);
- }
-
- /**
- * 清理设备所有区域的检测状态
- *
- * 用于工单切换场景,清理本地缓存。
- * Redis 数据(arrivedTime、signalLoss、rssiWindow)由以下路径清理:
- *
- * - 工单完成时:SignalLossRuleProcessor.cleanupRedisData()
- * - 自然过期:Redis TTL 自动清理
- * - 新数据覆盖:每次检测都会更新滑动窗口
- *
- *
- * @param deviceId 设备ID
- */
- private void cleanupAllDetectionState(Long deviceId) {
- if (deviceId == null) {
- return;
- }
- // 清理本地缓存
- lastDetectedOrderCache.remove(deviceId);
- log.info("[BeaconDetection] 已清理设备工单切换检测状态: deviceId={}", deviceId);
- }
-}
+package com.viewsh.module.iot.service.rule.clean.processor;
+
+import com.viewsh.framework.tenant.core.context.ProjectContextHolder;
+import com.viewsh.framework.tenant.core.context.TenantContextHolder;
+import com.viewsh.module.iot.core.integration.constants.CleanOrderTopics;
+import com.viewsh.module.iot.core.integration.event.clean.CleanOrderArriveEvent;
+import com.viewsh.module.iot.core.integration.event.clean.CleanOrderAuditEvent;
+import com.viewsh.module.iot.dal.dataobject.integration.clean.BeaconPresenceConfig;
+import com.viewsh.module.iot.dal.redis.clean.BadgeDeviceStatusRedisDAO;
+import com.viewsh.module.iot.dal.redis.clean.BeaconArrivedTimeRedisDAO;
+import com.viewsh.module.iot.dal.redis.clean.BeaconRssiWindowRedisDAO;
+import com.viewsh.module.iot.dal.redis.clean.SignalLossRedisDAO;
+import com.viewsh.module.iot.service.integration.clean.CleanOrderIntegrationConfigService;
+import com.viewsh.module.iot.service.rule.clean.detector.RssiSlidingWindowDetector;
+import jakarta.annotation.Resource;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.rocketmq.spring.core.RocketMQTemplate;
+import org.springframework.messaging.support.MessageBuilder;
+import org.springframework.stereotype.Component;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * 蓝牙信标检测规则处理器
+ *
+ * 监听工牌的蓝牙属性上报,基于滑动窗口算法检测保洁员到岗/离岗
+ * 采用"强进弱出"双阈值,避免信号抖动
+ *
+ * @author AI
+ */
+@Component
+@Slf4j
+public class BeaconDetectionRuleProcessor {
+
+ @Resource
+ private BeaconRssiWindowRedisDAO windowRedisDAO;
+
+ @Resource
+ private BeaconArrivedTimeRedisDAO arrivedTimeRedisDAO;
+
+ @Resource
+ private SignalLossRedisDAO signalLossRedisDAO;
+
+ @Resource
+ private BadgeDeviceStatusRedisDAO badgeDeviceStatusRedisDAO;
+
+ @Resource
+ private CleanOrderIntegrationConfigService configService;
+
+ @Resource
+ private RssiSlidingWindowDetector detector;
+
+ @Resource
+ private RocketMQTemplate rocketMQTemplate;
+
+ /**
+ * 上次检测的工单ID缓存(设备ID -> 工单ID)
+ * 用于检测工单切换,清理旧工单的检测状态
+ */
+ private final Map lastDetectedOrderCache = new ConcurrentHashMap<>();
+
+ /**
+ * 处理蓝牙属性上报
+ *
+ * 在设备属性上报处理流程中调用此方法
+ *
+ * @param deviceId 设备ID
+ * @param identifier 属性标识符(bluetoothDevices)
+ * @param propertyValue 属性值(蓝牙设备数组)
+ */
+ public void processPropertyChange(Long deviceId, String identifier, Object propertyValue) {
+ // 1. 检查是否是蓝牙属性
+ if (!"bluetoothDevices".equals(identifier)) {
+ return;
+ }
+
+ log.debug("[BeaconDetection] 收到蓝牙属性:deviceId={}", deviceId);
+
+ // 2. 先获取当前工单状态(从中获取正确的 areaId)
+ BadgeDeviceStatusRedisDAO.OrderInfo currentOrder = badgeDeviceStatusRedisDAO.getCurrentOrder(deviceId);
+
+ if (currentOrder == null || currentOrder.getAreaId() == null) {
+ log.debug("[BeaconDetection] 无当前工单,跳过检测:deviceId={}", deviceId);
+ // 无工单时清理本地缓存
+ lastDetectedOrderCache.remove(deviceId);
+ return;
+ }
+
+ Long areaId = currentOrder.getAreaId();
+ Long orderId = currentOrder.getOrderId();
+
+ // 3. 检测工单切换,清理旧工单的检测状态
+ Long lastOrderId = lastDetectedOrderCache.get(deviceId);
+ if (lastOrderId != null && !lastOrderId.equals(orderId)) {
+ log.warn("[BeaconDetection] 检测到工单切换,清理旧工单的检测状态: " +
+ "deviceId={}, oldOrderId={}, newOrderId={}", deviceId, lastOrderId, orderId);
+ // 清理旧的检测状态(清理当前设备的所有区域检测状态)
+ cleanupAllDetectionState(deviceId);
+ }
+ // 更新缓存
+ lastDetectedOrderCache.put(deviceId, orderId);
+
+ log.debug("[BeaconDetection] 从工单状态获取区域:deviceId={}, areaId={}, orderId={}",
+ deviceId, areaId, orderId);
+
+ // 3. 获取该区域的信标配置(从 BEACON 类型的设备获取)
+ CleanOrderIntegrationConfigService.AreaDeviceConfigWrapper beaconConfigWrapper = configService
+ .getConfigByAreaIdAndRelationType(areaId, "BEACON");
+
+ if (beaconConfigWrapper == null || beaconConfigWrapper.getConfig() == null) {
+ log.debug("[BeaconDetection] 区域无信标配置:areaId={}", areaId);
+ return;
+ }
+
+ BeaconPresenceConfig beaconConfig = beaconConfigWrapper.getConfig().getBeaconPresence();
+ if (beaconConfig == null || !beaconConfig.getEnabled()) {
+ log.debug("[BeaconDetection] 未启用信标检测:areaId={}", areaId);
+ return;
+ }
+
+ // 4. 解析蓝牙数据,提取目标信标的 RSSI
+ Integer targetRssi = detector.extractTargetRssi(propertyValue, beaconConfig);
+
+ log.debug("[BeaconDetection] 提取RSSI:deviceId={}, areaId={}, beaconMac={}, rssi={}",
+ deviceId, areaId, beaconConfig.getBeaconMac(), targetRssi);
+
+ // 5. 更新滑动窗口(使用 enter 和 exit 中较大的窗口大小)
+ int maxWindowSize = Math.max(
+ beaconConfig.getEnter().getWindowSize(),
+ beaconConfig.getExit().getWindowSize());
+ windowRedisDAO.updateWindow(deviceId, areaId, targetRssi, maxWindowSize);
+
+ // 6. 获取当前窗口样本
+ List window = windowRedisDAO.getWindow(deviceId, areaId);
+
+ // 7. 确定当前状态
+ RssiSlidingWindowDetector.AreaState currentState = determineState(currentOrder, areaId, deviceId);
+
+ // 8. 执行检测
+ RssiSlidingWindowDetector.DetectionResult result = detector.detect(
+ window,
+ beaconConfig.getEnter(),
+ beaconConfig.getExit(),
+ currentState);
+
+ // 9. 处理检测结果
+ switch (result) {
+ case ARRIVE_CONFIRMED:
+ handleArriveConfirmed(deviceId, areaId, window, beaconConfig, currentOrder);
+ break;
+ case LEAVE_CONFIRMED:
+ handleLeaveConfirmed(deviceId, areaId, window, beaconConfig);
+ break;
+ default:
+ // NO_CHANGE,不做处理
+ break;
+ }
+ }
+
+ /**
+ * 处理到达确认
+ */
+ private void handleArriveConfirmed(Long deviceId, Long areaId, List window,
+ BeaconPresenceConfig beaconConfig,
+ BadgeDeviceStatusRedisDAO.OrderInfo currentOrder) {
+ log.info("[BeaconDetection] 到达确认:deviceId={}, areaId={}, window={}",
+ deviceId, areaId, window);
+
+ // 1. 记录到达时间
+ arrivedTimeRedisDAO.recordArrivedTime(deviceId, areaId, System.currentTimeMillis());
+
+ // 2. 清除离岗记录(如果存在)
+ signalLossRedisDAO.clearLossRecord(deviceId, areaId);
+
+ // 3. 清理 RSSI 窗口(避免历史脏数据影响新的在岗周期)
+ windowRedisDAO.clearWindow(deviceId, areaId);
+ log.debug("[BeaconDetection] 到岗时清理RSSI窗口:deviceId={}, areaId={}", deviceId, areaId);
+
+ // 4. 获取当前最新的 RSSI 值(使用原窗口快照,因为已清理)
+ Integer currentRssi = window.isEmpty() ? -999 : window.get(window.size() - 1);
+
+ // 5. 构建触发数据
+ Map triggerData = new HashMap<>();
+ triggerData.put("beaconMac", beaconConfig.getBeaconMac());
+ triggerData.put("rssi", currentRssi);
+ triggerData.put("windowSnapshot", window);
+ triggerData.put("enterRssiThreshold", beaconConfig.getEnter().getRssiThreshold());
+
+ // 6. 发布到岗事件
+ if (beaconConfig.getEnter().getAutoArrival()) {
+ publishArriveEvent(deviceId, currentOrder.getOrderId(), areaId, triggerData);
+ }
+
+ // 7. 发布审计日志
+ publishAuditEvent("BEACON_ARRIVE_CONFIRMED", deviceId, null, areaId, currentOrder.getOrderId(),
+ "蓝牙信标自动到岗确认", triggerData);
+ }
+
+ /**
+ * 处理离开确认
+ */
+ private void handleLeaveConfirmed(Long deviceId, Long areaId, List window,
+ BeaconPresenceConfig beaconConfig) {
+ log.info("[BeaconDetection] 离开确认:deviceId={}, areaId={}, window={}",
+ deviceId, areaId, window);
+
+ // 注意:离岗警告阶段不清除arrivedTime,保持IN_AREA状态
+ // arrivedTime在工单完成时由SignalLossRuleProcessor.cleanupRedisData清除
+
+ // P0 插队校验:检查当前工单是否属于正在检查的区域
+ if (isSwitchingOrder(deviceId, areaId)) {
+ log.debug("[BeaconDetection][P0Interrupt] 检测到工单切换,跳过区域 {} 的离岗处理",
+ areaId);
+ // 清理该区域的离岗记录(避免内存泄漏)
+ signalLossRedisDAO.clearLossRecord(deviceId, areaId);
+ return;
+ }
+
+ BeaconPresenceConfig.ExitConfig exitConfig = beaconConfig.getExit();
+
+ // 1. 检查是否是首次丢失
+ Long firstLossTime = signalLossRedisDAO.getFirstLossTime(deviceId, areaId);
+
+ if (firstLossTime == null) {
+ // 首次丢失
+ signalLossRedisDAO.recordFirstLoss(deviceId, areaId, System.currentTimeMillis());
+
+ // 2. 发布审计日志
+ Map data = new HashMap<>();
+ data.put("firstLossTime", System.currentTimeMillis());
+ data.put("rssi", window.isEmpty() ? -999 : window.get(window.size() - 1));
+ data.put("warningDelayMinutes", exitConfig.getWarningDelayMinutes());
+
+ // 获取当前工单ID
+ BadgeDeviceStatusRedisDAO.OrderInfo currentOrder = badgeDeviceStatusRedisDAO.getCurrentOrder(deviceId);
+ Long orderId = currentOrder != null ? currentOrder.getOrderId() : null;
+
+ publishAuditEvent("BEACON_LEAVE_WARNING_SENT", deviceId, null, areaId, orderId,
+ "保洁员离开作业区域,已发送警告", data);
+ } else {
+ // 4. 更新最后丢失时间
+ signalLossRedisDAO.updateLastLossTime(deviceId, areaId, System.currentTimeMillis());
+
+ log.debug("[BeaconDetection] 更新最后丢失时间:deviceId={}, areaId={}", deviceId, areaId);
+ }
+ }
+
+ /**
+ * 发布到岗事件
+ */
+ private void publishArriveEvent(Long deviceId, Long orderId, Long areaId, Map triggerData) {
+ try {
+ CleanOrderArriveEvent event = CleanOrderArriveEvent.builder()
+ .tenantId(TenantContextHolder.getTenantId())
+ .projectId(ProjectContextHolder.getProjectId())
+ .eventId(java.util.UUID.randomUUID().toString())
+ .orderType("CLEAN")
+ .orderId(orderId)
+ .deviceId(deviceId)
+ .areaId(areaId)
+ .triggerSource("IOT_BEACON")
+ .triggerData(triggerData)
+ .build();
+
+ rocketMQTemplate.syncSend(CleanOrderTopics.ORDER_ARRIVE, MessageBuilder.withPayload(event).build());
+
+ log.info("[BeaconDetection] 发布到岗事件:eventId={}, deviceId={}, areaId={}, orderId={}",
+ event.getEventId(), deviceId, areaId, orderId);
+ } catch (Exception e) {
+ log.error("[BeaconDetection] 发布到岗事件失败:deviceId={}, areaId={}", deviceId, areaId, e);
+ }
+ }
+
+ /**
+ * 发布审计事件
+ */
+ private void publishAuditEvent(String auditType, Long deviceId, String deviceKey,
+ Long areaId, Long orderId, String message, Map data) {
+ try {
+ CleanOrderAuditEvent event = CleanOrderAuditEvent.builder()
+ .tenantId(TenantContextHolder.getTenantId())
+ .projectId(ProjectContextHolder.getProjectId())
+ .eventId(java.util.UUID.randomUUID().toString())
+ .auditType(auditType)
+ .deviceId(deviceId)
+ .deviceKey(deviceKey)
+ .areaId(areaId)
+ .orderId(orderId)
+ .message(message)
+ .data(data)
+ .build();
+
+ rocketMQTemplate.syncSend(CleanOrderTopics.ORDER_AUDIT, MessageBuilder.withPayload(event).build());
+
+ log.debug("[BeaconDetection] 发布审计事件:auditType={}, deviceId={}, areaId={}, orderId={}",
+ auditType, deviceId, areaId, orderId);
+ } catch (Exception e) {
+ log.error("[BeaconDetection] 发布审计事件失败:auditType={}, deviceId={}", auditType, deviceId, e);
+ }
+ }
+
+ /**
+ * 发布 TTS 事件(通过审计事件传递)
+ */
+ private void publishTtsEvent(Long deviceId, String text) {
+ Map data = new HashMap<>();
+ data.put("tts", text);
+ data.put("timestamp", System.currentTimeMillis());
+
+ publishAuditEvent("TTS_REQUEST", deviceId, null, null, null, text, data);
+ }
+
+ /**
+ * 确定当前状态
+ */
+ private RssiSlidingWindowDetector.AreaState determineState(
+ BadgeDeviceStatusRedisDAO.OrderInfo currentOrder, Long areaId, Long deviceId) {
+
+ // 优先检查IoT本地到岗记录(避免依赖跨模块异步同步)
+ Long arrivedTime = arrivedTimeRedisDAO.getArrivedTime(deviceId, areaId);
+ if (arrivedTime != null) {
+ log.debug("[BeaconDetection] 本地状态:已到岗, deviceId={}, areaId={}, arrivedTime={}",
+ deviceId, areaId, arrivedTime);
+ return RssiSlidingWindowDetector.AreaState.IN_AREA;
+ }
+
+ // 降级:检查Ops模块的工单状态(向后兼容)
+ if (currentOrder != null
+ && "ARRIVED".equals(currentOrder.getStatus())
+ && areaId.equals(currentOrder.getAreaId())) {
+ // 同步本地状态(修复历史数据)
+ arrivedTimeRedisDAO.recordArrivedTime(deviceId, areaId, System.currentTimeMillis());
+ log.info("[BeaconDetection] 从Ops状态同步本地到岗记录:deviceId={}, areaId={}",
+ deviceId, areaId);
+ return RssiSlidingWindowDetector.AreaState.IN_AREA;
+ }
+
+ return RssiSlidingWindowDetector.AreaState.OUT_AREA;
+ }
+
+ /**
+ * 检查是否正在切换工单(P0 插队场景)
+ *
+ * 如果当前工单的区域ID与正在检查的区域不一致,说明保洁员已切换到其他区域的工单
+ *
+ * @param deviceId 设备ID
+ * @param areaId 正在检查的区域ID
+ * @return true-工单切换场景,false-正常离岗场景
+ */
+ private boolean isSwitchingOrder(Long deviceId, Long areaId) {
+ BadgeDeviceStatusRedisDAO.OrderInfo currentOrder = badgeDeviceStatusRedisDAO.getCurrentOrder(deviceId);
+ return currentOrder != null && !currentOrder.getAreaId().equals(areaId);
+ }
+
+ /**
+ * 清理设备所有区域的检测状态
+ *
+ * 用于工单切换场景,清理本地缓存。
+ * Redis 数据(arrivedTime、signalLoss、rssiWindow)由以下路径清理:
+ *
+ * - 工单完成时:SignalLossRuleProcessor.cleanupRedisData()
+ * - 自然过期:Redis TTL 自动清理
+ * - 新数据覆盖:每次检测都会更新滑动窗口
+ *
+ *
+ * @param deviceId 设备ID
+ */
+ private void cleanupAllDetectionState(Long deviceId) {
+ if (deviceId == null) {
+ return;
+ }
+ // 清理本地缓存
+ lastDetectedOrderCache.remove(deviceId);
+ log.info("[BeaconDetection] 已清理设备工单切换检测状态: deviceId={}", deviceId);
+ }
+}
diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/rule/clean/processor/ButtonEventRuleProcessor.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/rule/clean/processor/ButtonEventRuleProcessor.java
index d4098576..fa664aab 100644
--- a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/rule/clean/processor/ButtonEventRuleProcessor.java
+++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/rule/clean/processor/ButtonEventRuleProcessor.java
@@ -1,297 +1,300 @@
-package com.viewsh.module.iot.service.rule.clean.processor;
-
-import com.viewsh.framework.common.util.json.JsonUtils;
-import com.viewsh.framework.tenant.core.context.TenantContextHolder;
-import com.viewsh.module.iot.core.integration.constants.CleanOrderTopics;
-import com.viewsh.module.iot.dal.dataobject.integration.clean.ButtonEventConfig;
-import com.viewsh.module.iot.dal.redis.clean.BadgeDeviceStatusRedisDAO;
-import com.viewsh.module.iot.service.device.IotDeviceService;
-import com.viewsh.module.iot.dal.dataobject.device.IotDeviceDO;
-import jakarta.annotation.Resource;
-import lombok.extern.slf4j.Slf4j;
-import org.apache.rocketmq.spring.core.RocketMQTemplate;
-import org.springframework.data.redis.core.StringRedisTemplate;
-import org.springframework.messaging.support.MessageBuilder;
-import org.springframework.stereotype.Component;
-
-import java.util.HashMap;
-import java.util.Map;
-import java.util.UUID;
-
-/**
- * 按键事件规则处理器
- *
- * 监听设备按键事件上报,处理保洁员工牌的按键交互
- *
- * 支持的按键类型:
- * - 确认键(confirmKeyId):保洁员确认接收工单
- * - 查询键(queryKeyId):保洁员查询当前工单信息
- *
- * @author AI
- */
-@Component
-@Slf4j
-public class ButtonEventRuleProcessor {
-
- @Resource
- private BadgeDeviceStatusRedisDAO badgeDeviceStatusRedisDAO;
-
- @Resource
- private IotDeviceService deviceService;
-
- @Resource
- private RocketMQTemplate rocketMQTemplate;
-
- /**
- * 处理按键事件属性上报
- *
- * 在设备属性上报处理流程中调用此方法
- *
- * @param deviceId 设备ID
- * @param identifier 属性标识符(如 button_event)
- * @param propertyValue 属性值
- */
- public void processPropertyChange(Long deviceId, String identifier, Object propertyValue) {
- // 1. 检查是否是按键事件属性
- if (!"button_event".equals(identifier)) {
- return;
- }
-
- log.debug("[ButtonEvent] 收到按键事件:deviceId={}, value={}", deviceId, propertyValue);
-
- // 2. 获取设备按键配置(从设备 config 字段读取)
- ButtonEventConfig buttonConfig = getButtonConfig(deviceId);
- if (buttonConfig == null || !buttonConfig.getEnabled()) {
- log.debug("[ButtonEvent] 未启用按键事件处理:deviceId={}", deviceId);
- return;
- }
-
- // 3. 解析按键ID
- Integer buttonId = parseButtonId(propertyValue);
- if (buttonId == null) {
- log.warn("[ButtonEvent] 按键ID解析失败:deviceId={}, value={}", deviceId, propertyValue);
- return;
- }
-
- log.debug("[ButtonEvent] 按键解析成功:deviceId={}, buttonId={}", deviceId, buttonId);
-
- // 4. 匹配按键类型并处理(确认键和查询键统一路由到同一逻辑)
- if (buttonId.equals(buttonConfig.getConfirmKeyId())
- || buttonId.equals(buttonConfig.getQueryKeyId())) {
- // 所有已知按键统一走绿色按键逻辑(根据工单状态智能判断行为)
- handleGreenButton(deviceId, buttonId);
- } else {
- log.debug("[ButtonEvent] 未配置的按键:deviceId={}, buttonId={}", deviceId, buttonId);
- }
- }
-
- /**
- * 处理绿色按键(统一按键逻辑)
- *
- * 根据当前工单状态智能判断行为:
- * - 无工单:发布查询事件(Ops 端播报"没有工单")
- * - DISPATCHED:发布确认事件(触发确认状态转换 + 停止循环 + 播报地点)
- * - CONFIRMED/ARRIVED:发布查询事件(播报地点)
- * - 其他状态:发布查询事件(兜底处理)
- */
- private void handleGreenButton(Long deviceId, Integer buttonId) {
- log.info("[ButtonEvent] 绿色按键按下:deviceId={}, buttonId={}", deviceId, buttonId);
-
- // 1. 查询设备当前工单
- BadgeDeviceStatusRedisDAO.OrderInfo currentOrder = badgeDeviceStatusRedisDAO.getCurrentOrder(deviceId);
- if (currentOrder == null) {
- // 无工单 → 发布查询事件(Ops 端播报"没有工单")
- log.info("[ButtonEvent] 设备无当前工单:deviceId={}", deviceId);
- publishQueryEvent(deviceId, null, buttonId, "当前无工单");
- return;
- }
-
- Long orderId = currentOrder.getOrderId();
- String orderStatus = currentOrder.getStatus();
-
- // 2. 根据工单状态智能分派
- if ("DISPATCHED".equals(orderStatus)) {
- // DISPATCHED → 发布确认事件(触发确认 + 停止循环 + 播报地点)
- // 防重复检查
- String dedupKey = String.format("iot:clean:button:dedup:confirm:%s:%s", deviceId, orderId);
- Boolean firstTime = stringRedisTemplate.opsForValue()
- .setIfAbsent(dedupKey, "1", 10, java.util.concurrent.TimeUnit.SECONDS);
-
- if (!Boolean.TRUE.equals(firstTime)) {
- // 重复确认不再静默,改为发查询事件给保洁员反馈(播报地点)
- log.info("[ButtonEvent] 确认操作重复,转为查询:deviceId={}, orderId={}", deviceId, orderId);
- publishQueryEvent(deviceId, orderId, buttonId, "重复确认,查询当前工单");
- return;
- }
-
- publishConfirmEvent(deviceId, orderId, buttonId);
- log.info("[ButtonEvent] DISPATCHED状态,发布确认事件:deviceId={}, orderId={}", deviceId, orderId);
- } else {
- // CONFIRMED / ARRIVED / 其他状态 → 发布查询事件(播报地点)
- publishQueryEvent(deviceId, orderId, buttonId, "查询当前工单");
- log.info("[ButtonEvent] {}状态,发布查询事件:deviceId={}, orderId={}", orderStatus, deviceId, orderId);
- }
- }
-
- /**
- * 发布工单确认事件
- */
- private void publishConfirmEvent(Long deviceId, Long orderId, Integer buttonId) {
- try {
- String deviceKey = getDeviceKey(deviceId);
-
- Map event = new HashMap<>();
- event.put("eventId", UUID.randomUUID().toString());
- event.put("tenantId", TenantContextHolder.getTenantId());
- event.put("orderType", "CLEAN");
- event.put("orderId", orderId);
- event.put("deviceId", deviceId);
- event.put("deviceKey", deviceKey);
- event.put("areaId", null); // areaId 由 Ops 模块从当前工单获取
- event.put("triggerSource", "IOT_BUTTON_CONFIRM");
- event.put("buttonId", buttonId);
-
- rocketMQTemplate.syncSend(
- CleanOrderTopics.ORDER_CONFIRM,
- MessageBuilder.withPayload(event).build()
- );
-
- log.info("[ButtonEvent] 确认事件已发布:eventId={}, orderId={}, deviceId={}",
- event.get("eventId"), orderId, deviceId);
- } catch (Exception e) {
- log.error("[ButtonEvent] 发布确认事件失败:deviceId={}, orderId={}",
- deviceId, orderId, e);
- }
- }
-
- /**
- * 发布工单查询事件
- */
- private void publishQueryEvent(Long deviceId, Long orderId, Integer buttonId, String message) {
- try {
- String deviceKey = getDeviceKey(deviceId);
-
- Map event = new HashMap<>();
- event.put("eventId", UUID.randomUUID().toString());
- event.put("tenantId", TenantContextHolder.getTenantId());
- event.put("orderType", "CLEAN");
- event.put("orderId", orderId);
- event.put("deviceId", deviceId);
- event.put("deviceKey", deviceKey);
- event.put("areaId", null); // areaId 由 Ops 模块从当前工单获取
- event.put("triggerSource", "IOT_BUTTON_QUERY");
- event.put("buttonId", buttonId);
- event.put("message", message);
-
- rocketMQTemplate.syncSend(
- CleanOrderTopics.ORDER_AUDIT, // 查询事件使用审计主题
- MessageBuilder.withPayload(event).build()
- );
-
- log.info("[ButtonEvent] 查询事件已发布:eventId={}, orderId={}, deviceId={}, message={}",
- event.get("eventId"), orderId, deviceId, message);
- } catch (Exception e) {
- log.error("[ButtonEvent] 发布查询事件失败:deviceId={}, orderId={}",
- deviceId, orderId, e);
- }
- }
-
- /**
- * 获取设备按键配置
- *
- * 从设备的 config 字段读取按键事件配置
- *
- * @param deviceId 设备ID
- * @return 按键配置,如果未配置返回 null
- */
- private ButtonEventConfig getButtonConfig(Long deviceId) {
- try {
- IotDeviceDO device = deviceService.getDeviceFromCache(deviceId);
- if (device == null || device.getConfig() == null) {
- log.debug("[ButtonEvent] 设备不存在或无配置:deviceId={}", deviceId);
- return null;
- }
-
- // 从设备 config JSON 中解析 buttonEvent 配置
- // 注意:使用 JsonUtils.parseObject 直接解析整个 config 为 Map,然后提取 buttonEvent
- // 避免 先转JSON字符串再解析回对象 的双重转换
- @SuppressWarnings("unchecked")
- Map configMap = JsonUtils.parseObject(device.getConfig(), Map.class);
- if (configMap == null || !configMap.containsKey("buttonEvent")) {
- log.debug("[ButtonEvent] 设备配置中无 buttonEvent:deviceId={}", deviceId);
- return null;
- }
-
- // 将 buttonEvent 对象转为 JSON 字符串再解析为目标类型
- // TODO: 后续可优化为直接转换,避免序列化/反序列化开销
- Object buttonEventObj = configMap.get("buttonEvent");
- return JsonUtils.parseObject(JsonUtils.toJsonString(buttonEventObj), ButtonEventConfig.class);
- } catch (Exception e) {
- log.error("[ButtonEvent] 获取按键配置失败:deviceId={}", deviceId, e);
- return null;
- }
- }
-
- /**
- * 解析按键ID
- *
- * 支持两种格式:
- * 1. 属性上报:value 直接是按键ID(如 1)
- * 2. 事件上报:value 是 Map,包含 keyId 字段(如 {keyId: 1, keyState: 1})
- */
- @SuppressWarnings("unchecked")
- private Integer parseButtonId(Object value) {
- if (value == null) {
- return null;
- }
-
- // 事件上报格式:value 是 Map,包含 keyId 字段
- if (value instanceof Map) {
- Map map = (Map) value;
- Object keyId = map.get("keyId");
- if (keyId instanceof Number) {
- return ((Number) keyId).intValue();
- }
- return null;
- }
-
- // 属性上报格式:value 直接是按键ID
- if (value instanceof Number) {
- return ((Number) value).intValue();
- }
-
- if (value instanceof String) {
- try {
- return Integer.parseInt((String) value);
- } catch (NumberFormatException e) {
- return null;
- }
- }
-
- return null;
- }
-
- /**
- * 获取设备 Key(从 IoT 设备缓存获取 serialNumber)
- *
- * deviceKey 在 ops_area_device_relation 表中是冗余字段,
- * 实际来源是 iot_device.serialNumber
- *
- * @param deviceId 设备ID
- * @return deviceKey(serialNumber),获取��败返回 null
- */
- private String getDeviceKey(Long deviceId) {
- try {
- IotDeviceDO device = deviceService.getDeviceFromCache(deviceId);
- if (device != null) {
- return device.getSerialNumber();
- }
- } catch (Exception e) {
- log.warn("[ButtonEvent] 获取 deviceKey 失败:deviceId={}", deviceId, e);
- }
- return null;
- }
-
- @Resource
- private StringRedisTemplate stringRedisTemplate;
-}
+package com.viewsh.module.iot.service.rule.clean.processor;
+
+import com.viewsh.framework.common.util.json.JsonUtils;
+import com.viewsh.framework.tenant.core.context.ProjectContextHolder;
+import com.viewsh.framework.tenant.core.context.TenantContextHolder;
+import com.viewsh.module.iot.core.integration.constants.CleanOrderTopics;
+import com.viewsh.module.iot.dal.dataobject.integration.clean.ButtonEventConfig;
+import com.viewsh.module.iot.dal.redis.clean.BadgeDeviceStatusRedisDAO;
+import com.viewsh.module.iot.service.device.IotDeviceService;
+import com.viewsh.module.iot.dal.dataobject.device.IotDeviceDO;
+import jakarta.annotation.Resource;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.rocketmq.spring.core.RocketMQTemplate;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.messaging.support.MessageBuilder;
+import org.springframework.stereotype.Component;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.UUID;
+
+/**
+ * 按键事件规则处理器
+ *
+ * 监听设备按键事件上报,处理保洁员工牌的按键交互
+ *
+ * 支持的按键类型:
+ * - 确认键(confirmKeyId):保洁员确认接收工单
+ * - 查询键(queryKeyId):保洁员查询当前工单信息
+ *
+ * @author AI
+ */
+@Component
+@Slf4j
+public class ButtonEventRuleProcessor {
+
+ @Resource
+ private BadgeDeviceStatusRedisDAO badgeDeviceStatusRedisDAO;
+
+ @Resource
+ private IotDeviceService deviceService;
+
+ @Resource
+ private RocketMQTemplate rocketMQTemplate;
+
+ /**
+ * 处理按键事件属性上报
+ *
+ * 在设备属性上报处理流程中调用此方法
+ *
+ * @param deviceId 设备ID
+ * @param identifier 属性标识符(如 button_event)
+ * @param propertyValue 属性值
+ */
+ public void processPropertyChange(Long deviceId, String identifier, Object propertyValue) {
+ // 1. 检查是否是按键事件属性
+ if (!"button_event".equals(identifier)) {
+ return;
+ }
+
+ log.debug("[ButtonEvent] 收到按键事件:deviceId={}, value={}", deviceId, propertyValue);
+
+ // 2. 获取设备按键配置(从设备 config 字段读取)
+ ButtonEventConfig buttonConfig = getButtonConfig(deviceId);
+ if (buttonConfig == null || !buttonConfig.getEnabled()) {
+ log.debug("[ButtonEvent] 未启用按键事件处理:deviceId={}", deviceId);
+ return;
+ }
+
+ // 3. 解析按键ID
+ Integer buttonId = parseButtonId(propertyValue);
+ if (buttonId == null) {
+ log.warn("[ButtonEvent] 按键ID解析失败:deviceId={}, value={}", deviceId, propertyValue);
+ return;
+ }
+
+ log.debug("[ButtonEvent] 按键解析成功:deviceId={}, buttonId={}", deviceId, buttonId);
+
+ // 4. 匹配按键类型并处理(确认键和查询键统一路由到同一逻辑)
+ if (buttonId.equals(buttonConfig.getConfirmKeyId())
+ || buttonId.equals(buttonConfig.getQueryKeyId())) {
+ // 所有已知按键统一走绿色按键逻辑(根据工单状态智能判断行为)
+ handleGreenButton(deviceId, buttonId);
+ } else {
+ log.debug("[ButtonEvent] 未配置的按键:deviceId={}, buttonId={}", deviceId, buttonId);
+ }
+ }
+
+ /**
+ * 处理绿色按键(统一按键逻辑)
+ *
+ * 根据当前工单状态智能判断行为:
+ * - 无工单:发布查询事件(Ops 端播报"没有工单")
+ * - DISPATCHED:发布确认事件(触发确认状态转换 + 停止循环 + 播报地点)
+ * - CONFIRMED/ARRIVED:发布查询事件(播报地点)
+ * - 其他状态:发布查询事件(兜底处理)
+ */
+ private void handleGreenButton(Long deviceId, Integer buttonId) {
+ log.info("[ButtonEvent] 绿色按键按下:deviceId={}, buttonId={}", deviceId, buttonId);
+
+ // 1. 查询设备当前工单
+ BadgeDeviceStatusRedisDAO.OrderInfo currentOrder = badgeDeviceStatusRedisDAO.getCurrentOrder(deviceId);
+ if (currentOrder == null) {
+ // 无工单 → 发布查询事件(Ops 端播报"没有工单")
+ log.info("[ButtonEvent] 设备无当前工单:deviceId={}", deviceId);
+ publishQueryEvent(deviceId, null, buttonId, "当前无工单");
+ return;
+ }
+
+ Long orderId = currentOrder.getOrderId();
+ String orderStatus = currentOrder.getStatus();
+
+ // 2. 根据工单状态智能分派
+ if ("DISPATCHED".equals(orderStatus)) {
+ // DISPATCHED → 发布确认事件(触发确认 + 停止循环 + 播报地点)
+ // 防重复检查
+ String dedupKey = String.format("iot:clean:button:dedup:confirm:%s:%s", deviceId, orderId);
+ Boolean firstTime = stringRedisTemplate.opsForValue()
+ .setIfAbsent(dedupKey, "1", 10, java.util.concurrent.TimeUnit.SECONDS);
+
+ if (!Boolean.TRUE.equals(firstTime)) {
+ // 重复确认不再静默,改为发查询事件给保洁员反馈(播报地点)
+ log.info("[ButtonEvent] 确认操作重复,转为查询:deviceId={}, orderId={}", deviceId, orderId);
+ publishQueryEvent(deviceId, orderId, buttonId, "重复确认,查询当前工单");
+ return;
+ }
+
+ publishConfirmEvent(deviceId, orderId, buttonId);
+ log.info("[ButtonEvent] DISPATCHED状态,发布确认事件:deviceId={}, orderId={}", deviceId, orderId);
+ } else {
+ // CONFIRMED / ARRIVED / 其他状态 → 发布查询事件(播报地点)
+ publishQueryEvent(deviceId, orderId, buttonId, "查询当前工单");
+ log.info("[ButtonEvent] {}状态,发布查询事件:deviceId={}, orderId={}", orderStatus, deviceId, orderId);
+ }
+ }
+
+ /**
+ * 发布工单确认事件
+ */
+ private void publishConfirmEvent(Long deviceId, Long orderId, Integer buttonId) {
+ try {
+ String deviceKey = getDeviceKey(deviceId);
+
+ Map event = new HashMap<>();
+ event.put("eventId", UUID.randomUUID().toString());
+ event.put("tenantId", TenantContextHolder.getTenantId());
+ event.put("projectId", ProjectContextHolder.getProjectId());
+ event.put("orderType", "CLEAN");
+ event.put("orderId", orderId);
+ event.put("deviceId", deviceId);
+ event.put("deviceKey", deviceKey);
+ event.put("areaId", null); // areaId 由 Ops 模块从当前工单获取
+ event.put("triggerSource", "IOT_BUTTON_CONFIRM");
+ event.put("buttonId", buttonId);
+
+ rocketMQTemplate.syncSend(
+ CleanOrderTopics.ORDER_CONFIRM,
+ MessageBuilder.withPayload(event).build()
+ );
+
+ log.info("[ButtonEvent] 确认事件已发布:eventId={}, orderId={}, deviceId={}",
+ event.get("eventId"), orderId, deviceId);
+ } catch (Exception e) {
+ log.error("[ButtonEvent] 发布确认事件失败:deviceId={}, orderId={}",
+ deviceId, orderId, e);
+ }
+ }
+
+ /**
+ * 发布工单查询事件
+ */
+ private void publishQueryEvent(Long deviceId, Long orderId, Integer buttonId, String message) {
+ try {
+ String deviceKey = getDeviceKey(deviceId);
+
+ Map event = new HashMap<>();
+ event.put("eventId", UUID.randomUUID().toString());
+ event.put("tenantId", TenantContextHolder.getTenantId());
+ event.put("projectId", ProjectContextHolder.getProjectId());
+ event.put("orderType", "CLEAN");
+ event.put("orderId", orderId);
+ event.put("deviceId", deviceId);
+ event.put("deviceKey", deviceKey);
+ event.put("areaId", null); // areaId 由 Ops 模块从当前工单获取
+ event.put("triggerSource", "IOT_BUTTON_QUERY");
+ event.put("buttonId", buttonId);
+ event.put("message", message);
+
+ rocketMQTemplate.syncSend(
+ CleanOrderTopics.ORDER_AUDIT, // 查询事件使用审计主题
+ MessageBuilder.withPayload(event).build()
+ );
+
+ log.info("[ButtonEvent] 查询事件已发布:eventId={}, orderId={}, deviceId={}, message={}",
+ event.get("eventId"), orderId, deviceId, message);
+ } catch (Exception e) {
+ log.error("[ButtonEvent] 发布查询事件失败:deviceId={}, orderId={}",
+ deviceId, orderId, e);
+ }
+ }
+
+ /**
+ * 获取设备按键配置
+ *
+ * 从设备的 config 字段读取按键事件配置
+ *
+ * @param deviceId 设备ID
+ * @return 按键配置,如果未配置返回 null
+ */
+ private ButtonEventConfig getButtonConfig(Long deviceId) {
+ try {
+ IotDeviceDO device = deviceService.getDeviceFromCache(deviceId);
+ if (device == null || device.getConfig() == null) {
+ log.debug("[ButtonEvent] 设备不存在或无配置:deviceId={}", deviceId);
+ return null;
+ }
+
+ // 从设备 config JSON 中解析 buttonEvent 配置
+ // 注意:使用 JsonUtils.parseObject 直接解析整个 config 为 Map,然后提取 buttonEvent
+ // 避免 先转JSON字符串再解析回对象 的双重转换
+ @SuppressWarnings("unchecked")
+ Map configMap = JsonUtils.parseObject(device.getConfig(), Map.class);
+ if (configMap == null || !configMap.containsKey("buttonEvent")) {
+ log.debug("[ButtonEvent] 设备配置中无 buttonEvent:deviceId={}", deviceId);
+ return null;
+ }
+
+ // 将 buttonEvent 对象转为 JSON 字符串再解析为目标类型
+ // TODO: 后续可优化为直接转换,避免序列化/反序列化开销
+ Object buttonEventObj = configMap.get("buttonEvent");
+ return JsonUtils.parseObject(JsonUtils.toJsonString(buttonEventObj), ButtonEventConfig.class);
+ } catch (Exception e) {
+ log.error("[ButtonEvent] 获取按键配置失败:deviceId={}", deviceId, e);
+ return null;
+ }
+ }
+
+ /**
+ * 解析按键ID
+ *
+ * 支持两种格式:
+ * 1. 属性上报:value 直接是按键ID(如 1)
+ * 2. 事件上报:value 是 Map,包含 keyId 字段(如 {keyId: 1, keyState: 1})
+ */
+ @SuppressWarnings("unchecked")
+ private Integer parseButtonId(Object value) {
+ if (value == null) {
+ return null;
+ }
+
+ // 事件上报格式:value 是 Map,包含 keyId 字段
+ if (value instanceof Map) {
+ Map map = (Map) value;
+ Object keyId = map.get("keyId");
+ if (keyId instanceof Number) {
+ return ((Number) keyId).intValue();
+ }
+ return null;
+ }
+
+ // 属性上报格式:value 直接是按键ID
+ if (value instanceof Number) {
+ return ((Number) value).intValue();
+ }
+
+ if (value instanceof String) {
+ try {
+ return Integer.parseInt((String) value);
+ } catch (NumberFormatException e) {
+ return null;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * 获取设备 Key(从 IoT 设备缓存获取 serialNumber)
+ *
+ * deviceKey 在 ops_area_device_relation 表中是冗余字段,
+ * 实际来源是 iot_device.serialNumber
+ *
+ * @param deviceId 设备ID
+ * @return deviceKey(serialNumber),获取��败返回 null
+ */
+ private String getDeviceKey(Long deviceId) {
+ try {
+ IotDeviceDO device = deviceService.getDeviceFromCache(deviceId);
+ if (device != null) {
+ return device.getSerialNumber();
+ }
+ } catch (Exception e) {
+ log.warn("[ButtonEvent] 获取 deviceKey 失败:deviceId={}", deviceId, e);
+ }
+ return null;
+ }
+
+ @Resource
+ private StringRedisTemplate stringRedisTemplate;
+}
diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/rule/clean/processor/SignalLossRuleProcessor.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/rule/clean/processor/SignalLossRuleProcessor.java
index 73e4e557..424a4a7a 100644
--- a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/rule/clean/processor/SignalLossRuleProcessor.java
+++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/rule/clean/processor/SignalLossRuleProcessor.java
@@ -1,384 +1,387 @@
-package com.viewsh.module.iot.service.rule.clean.processor;
-
-import com.viewsh.framework.tenant.core.context.TenantContextHolder;
-import com.viewsh.module.iot.core.integration.constants.CleanOrderTopics;
-import com.viewsh.module.iot.core.integration.event.clean.CleanOrderAuditEvent;
-import com.viewsh.module.iot.core.integration.event.clean.CleanOrderCompleteEvent;
-import com.viewsh.module.iot.dal.dataobject.integration.clean.BeaconPresenceConfig;
-import com.viewsh.module.iot.dal.redis.clean.BadgeDeviceStatusRedisDAO;
-import com.viewsh.module.iot.dal.redis.clean.BeaconArrivedTimeRedisDAO;
-import com.viewsh.module.iot.dal.redis.clean.BeaconRssiWindowRedisDAO;
-import com.viewsh.module.iot.dal.redis.clean.SignalLossRedisDAO;
-import com.viewsh.module.iot.service.integration.clean.CleanOrderIntegrationConfigService;
-import com.viewsh.module.iot.service.device.IotDeviceService;
-import com.viewsh.module.iot.dal.dataobject.device.IotDeviceDO;
-import com.xxl.job.core.handler.annotation.XxlJob;
-import jakarta.annotation.Resource;
-import lombok.extern.slf4j.Slf4j;
-import org.apache.rocketmq.spring.core.RocketMQTemplate;
-import com.viewsh.framework.tenant.core.util.TenantUtils;
-import org.springframework.messaging.support.MessageBuilder;
-import org.springframework.stereotype.Component;
-
-import java.util.HashMap;
-import java.util.Map;
-import java.util.Set;
-import org.springframework.data.redis.core.StringRedisTemplate;
-
-/**
- * 信号丢失规则处理器
- *
- * 定时检查离岗后N分钟则触发工单自动完成
- * 包含作业时长有效性校验,防止"打卡即走"作弊
- *
- * @author AI
- */
-@Component
-@Slf4j
-public class SignalLossRuleProcessor {
-
- @Resource
- private SignalLossRedisDAO signalLossRedisDAO;
-
- @Resource
- private BeaconArrivedTimeRedisDAO arrivedTimeRedisDAO;
-
- @Resource
- private BeaconRssiWindowRedisDAO windowRedisDAO;
-
- @Resource
- private BadgeDeviceStatusRedisDAO badgeDeviceStatusRedisDAO;
-
- @Resource
- private CleanOrderIntegrationConfigService configService;
-
- @Resource
- private IotDeviceService deviceService;
-
- @Resource
- private RocketMQTemplate rocketMQTemplate;
-
- @Resource
- private StringRedisTemplate stringRedisTemplate;
-
- /**
- * Redis Key 模式:扫描所有离岗记录
- */
- private static final String LOSS_KEY_PATTERN = "iot:clean:signal:loss:*";
-
- /**
- * 定时检查离岗超时(每 30 秒执行一次)
- *
- * 遍历所有离岗记录,检查是否超过超时时间
- * 如果超过,则触发工单完成
- */
- @XxlJob("signalLossCheckJob")
- public String checkLossTimeout() {
- // TODO: 设置租户上下文(单租户场景使用固定租户ID=1)
- // 确保后续发送的 RocketMQ 消息正确携带租户信息
- return doCheckLossTimeout();
- }
-
- /**
- * 执行离岗超时检查
- */
- private String doCheckLossTimeout() {
- try {
- log.debug("[SignalLoss] 开始检查离岗超时");
-
- // 1. 扫描所有离岗记录的 Key
- Set keys = stringRedisTemplate.keys(LOSS_KEY_PATTERN);
-
- if (keys == null || keys.isEmpty()) {
- return "暂无";
- }
-
- log.debug("[SignalLoss] 发现 {} 条离岗记录", keys.size());
-
- // 2. 遍历每条记录
- for (String key : keys) {
- try {
- // 解析 deviceId 和 areaId
- // Key 格式:iot:clean:signal:loss:{deviceId}:{areaId}
- String[] parts = key.split(":");
- if (parts.length < 6) {
- continue;
- }
-
- Long deviceId = Long.parseLong(parts[4]);
- Long areaId = Long.parseLong(parts[5]);
-
- // 检查超时
- IotDeviceDO device = deviceService.getDevice(deviceId);
- if (device == null || device.getTenantId() == null) {
- log.warn("[SignalLoss] 璁惧涓嶅瓨鍦ㄦ垨缂哄皯绉熸埛淇℃伅: deviceId={}", deviceId);
- continue;
- }
- TenantUtils.execute(device.getTenantId(), () -> checkTimeoutForDevice(deviceId, areaId));
-
- } catch (Exception e) {
- log.error("[SignalLoss] 处理离岗记录失败:key={}", key, e);
- }
- }
-
- } catch (Exception e) {
- log.error("[SignalLoss] 检查离岗超时失败", e);
- return "ERROR: " + e.getMessage();
- }
-
- return "SUCCESS";
- }
-
- /**
- * 检查单个设备的离岗超时
- */
- private void checkTimeoutForDevice(Long deviceId, Long areaId) {
- // P0 插队校验:检查当前工单是否属于正在检查的区域
- if (isSwitchingOrder(deviceId, areaId)) {
- log.debug("[SignalLoss][P0Interrupt] 检测到工单切换,跳过区域 {} 的超时检查",
- areaId);
- // 清理该区域的离岗记录(避免内存泄漏)
- signalLossRedisDAO.clearLossRecord(deviceId, areaId);
- return;
- }
-
- // 1. 获取该区域的信标配置(从 BEACON 类型的设备获取)
- CleanOrderIntegrationConfigService.AreaDeviceConfigWrapper beaconConfigWrapper = configService
- .getConfigByAreaIdAndRelationType(areaId, "BEACON");
-
- if (beaconConfigWrapper == null || beaconConfigWrapper.getConfig() == null ||
- beaconConfigWrapper.getConfig().getBeaconPresence() == null) {
- log.debug("[SignalLoss] 区域无信标配置:areaId={}", areaId);
- return;
- }
-
- BeaconPresenceConfig.ExitConfig exitConfig = beaconConfigWrapper.getConfig().getBeaconPresence().getExit();
-
- // 2. 获取 deviceKey(从 IoT 设备缓存获取 serialNumber)
- String badgeDeviceKey = getDeviceKey(deviceId);
-
- // 3. 获取首次丢失时间
- Long firstLossTime = signalLossRedisDAO.getFirstLossTime(deviceId, areaId);
-
- if (firstLossTime == null) {
- return;
- }
-
- // 3. 获取最后丢失时间
- Long lastLossTime = signalLossRedisDAO.getLastLossTime(deviceId, areaId);
-
- if (lastLossTime == null) {
- return;
- }
-
- // 4. 检查是否超时
- long timeoutMillis = exitConfig.getLossTimeoutMinutes() * 60000L;
- long elapsedMillis = System.currentTimeMillis() - firstLossTime;
-
- if (elapsedMillis < timeoutMillis) {
- log.debug("[SignalLoss] 未超时:deviceId={}, elapsed={}ms, timeout={}ms",
- deviceId, elapsedMillis, timeoutMillis);
- return;
- }
-
- log.info("[SignalLoss] 检测到离岗超时:deviceId={}, areaId={}, elapsed={}ms",
- deviceId, areaId, elapsedMillis);
-
- // 5. 有效性校验:检查作业时长
- Long arrivedAt = arrivedTimeRedisDAO.getArrivedTime(deviceId, areaId);
-
- if (arrivedAt == null) {
- log.warn("[SignalLoss] 未找到到达时间记录:deviceId={}, areaId={}", deviceId, areaId);
- signalLossRedisDAO.clearLossRecord(deviceId, areaId);
- return;
- }
-
- long durationMs = lastLossTime - arrivedAt;
- long minValidWorkMillis = exitConfig.getMinValidWorkMinutes() * 60000L;
-
- // 6. 分支处理:有效 vs 无效作业
- // TODO 暂时取消作业时长不足抑制自动完成的逻辑,所有情况均触发完成
- // if (durationMs < minValidWorkMillis) {
- // // 作业时长不足,抑制完成
- // handleInvalidWork(deviceId, badgeDeviceKey, areaId,
- // durationMs, minValidWorkMillis, exitConfig);
- // } else {
- // 作业时长有效,触发完成
- handleTimeoutComplete(deviceId, badgeDeviceKey, areaId,
- durationMs, lastLossTime);
- // }
- }
-
- /**
- * 处理无效作业(时长不足)
- */
- private void handleInvalidWork(Long deviceId, String deviceKey, Long areaId,
- Long durationMs, Long minValidWorkMillis,
- BeaconPresenceConfig.ExitConfig exitConfig) {
- log.warn("[SignalLoss] 作业时长不足,抑制自动完成:deviceId={}, duration={}ms, minRequired={}ms",
- deviceId, durationMs, minValidWorkMillis);
-
- // 1. 发送 TTS 警告
- publishTtsEvent(deviceId, "工单作业时长异常,请回到作业区域继续完成");
-
- // 2. 发布审计日志
- Map data = new HashMap<>();
- data.put("durationMs", durationMs);
- data.put("minValidWorkMinutes", exitConfig.getMinValidWorkMinutes());
- data.put("shortageMs", minValidWorkMillis - durationMs);
-
- // 获取当前工单ID
- BadgeDeviceStatusRedisDAO.OrderInfo currentOrder = badgeDeviceStatusRedisDAO.getCurrentOrder(deviceId);
- Long orderId = currentOrder != null ? currentOrder.getOrderId() : null;
-
- publishAuditEvent("COMPLETE_SUPPRESSED_INVALID", deviceId, deviceKey, areaId, orderId,
- "作业时长不足,抑制自动完成", data);
-
- // 3. 清除丢失记录(允许重新进入)
- signalLossRedisDAO.clearLossRecord(deviceId, areaId);
-
- // 4. 清除无效作业标记(允许下次警告)
- signalLossRedisDAO.markInvalidWorkNotified(deviceId, areaId);
- }
-
- /**
- * 处理超时自动完成
- */
- private void handleTimeoutComplete(Long deviceId, String deviceKey, Long areaId,
- Long durationMs, Long lastLossTime) {
- log.info("[SignalLoss] 触发自动完成:deviceId={}, areaId={}, duration={}ms",
- deviceId, areaId, durationMs);
-
- // 1. 获取当前工单
- BadgeDeviceStatusRedisDAO.OrderInfo currentOrder = badgeDeviceStatusRedisDAO.getCurrentOrder(deviceId);
-
- if (currentOrder == null) {
- log.warn("[SignalLoss] 设备无当前工单:deviceId={}", deviceId);
- return;
- }
-
- // 2. 构建触发数据
- Map triggerData = new HashMap<>();
- triggerData.put("durationMs", durationMs);
- triggerData.put("lastLossTime", lastLossTime);
- triggerData.put("completionReason", "SIGNAL_LOSS_TIMEOUT");
-
- // 3. 发布完成事件
- try {
- CleanOrderCompleteEvent event = CleanOrderCompleteEvent.builder()
- .tenantId(TenantContextHolder.getTenantId())
- .eventId(java.util.UUID.randomUUID().toString())
- .orderType("CLEAN")
- .orderId(currentOrder.getOrderId())
- .deviceId(deviceId)
- .deviceKey(deviceKey)
- .areaId(areaId)
- .triggerSource("IOT_SIGNAL_LOSS")
- .triggerData(triggerData)
- .build();
-
- rocketMQTemplate.syncSend(CleanOrderTopics.ORDER_COMPLETE, MessageBuilder.withPayload(event).build());
-
- log.info("[SignalLoss] 发布完成事件:eventId={}, orderId={}, duration={}ms",
- event.getEventId(), currentOrder.getOrderId(), durationMs);
- } catch (Exception e) {
- log.error("[SignalLoss] 发布完成事件失败:deviceId={}, orderId={}",
- deviceId, currentOrder.getOrderId(), e);
- return;
- }
-
- // 4. 发布审计日志
- Map auditData = new HashMap<>();
- auditData.put("durationMs", durationMs);
- auditData.put("lastLossTime", lastLossTime);
-
- publishAuditEvent("BEACON_COMPLETE_REQUESTED", deviceId, deviceKey, areaId, currentOrder.getOrderId(),
- "信号丢失超时自动完成", auditData);
-
- // 5. 清理 Redis 数据
- cleanupRedisData(deviceId, areaId);
- }
-
- /**
- * 清理 Redis 数据
- */
- private void cleanupRedisData(Long deviceId, Long areaId) {
- signalLossRedisDAO.clearLossRecord(deviceId, areaId);
- arrivedTimeRedisDAO.clearArrivedTime(deviceId, areaId);
- windowRedisDAO.clearWindow(deviceId, areaId);
-
- log.debug("[SignalLoss] 清理 Redis 数据:deviceId={}, areaId={}", deviceId, areaId);
- }
-
- /**
- * 发布审计事件
- */
- private void publishAuditEvent(String auditType, Long deviceId, String deviceKey,
- Long areaId, Long orderId, String message, Map data) {
- try {
- CleanOrderAuditEvent event = CleanOrderAuditEvent.builder()
- .tenantId(TenantContextHolder.getTenantId())
- .eventId(java.util.UUID.randomUUID().toString())
- .auditType(auditType)
- .deviceId(deviceId)
- .deviceKey(deviceKey)
- .areaId(areaId)
- .orderId(orderId)
- .message(message)
- .data(data)
- .build();
-
- rocketMQTemplate.syncSend(CleanOrderTopics.ORDER_AUDIT, MessageBuilder.withPayload(event).build());
-
- log.debug("[SignalLoss] 发布审计事件:auditType={}, deviceId={}, orderId={}", auditType, deviceId, orderId);
- } catch (Exception e) {
- log.error("[SignalLoss] 发布审计事件失败:auditType={}, deviceId={}", auditType, deviceId, e);
- }
- }
-
- /**
- * 发布 TTS 事件
- */
- private void publishTtsEvent(Long deviceId, String text) {
- Map data = new HashMap<>();
- data.put("tts", text);
- data.put("timestamp", System.currentTimeMillis());
-
- publishAuditEvent("TTS_REQUEST", deviceId, null, null, null, text, data);
- }
-
- /**
- * 检查是否正在切换工单(P0 插队场景)
- *
- * 如果当前工单的区域ID与正在检查的区域不一致,说明保洁员已切换到其他区域的工单
- *
- * @param deviceId 设备ID
- * @param areaId 正在检查的区域ID
- * @return true-工单切换场景,false-正常离岗场景
- */
- private boolean isSwitchingOrder(Long deviceId, Long areaId) {
- BadgeDeviceStatusRedisDAO.OrderInfo currentOrder = badgeDeviceStatusRedisDAO.getCurrentOrder(deviceId);
- return currentOrder != null && !currentOrder.getAreaId().equals(areaId);
- }
-
- /**
- * 获取设备 Key(从 IoT 设备缓存获取 serialNumber)
- *
- * deviceKey 在 ops_area_device_relation 表中是冗余字段,
- * 实际来源是 iot_device.serialNumber
- *
- * @param deviceId 设备ID
- * @return deviceKey(serialNumber),获取失败返回 null
- */
- private String getDeviceKey(Long deviceId) {
- try {
- IotDeviceDO device = deviceService.getDeviceFromCache(deviceId);
- if (device != null) {
- return device.getSerialNumber();
- }
- } catch (Exception e) {
- log.warn("[SignalLoss] 获取 deviceKey 失败:deviceId={}", deviceId, e);
- }
- return null;
- }
-}
+package com.viewsh.module.iot.service.rule.clean.processor;
+
+import com.viewsh.framework.tenant.core.context.ProjectContextHolder;
+import com.viewsh.framework.tenant.core.context.TenantContextHolder;
+import com.viewsh.module.iot.core.integration.constants.CleanOrderTopics;
+import com.viewsh.module.iot.core.integration.event.clean.CleanOrderAuditEvent;
+import com.viewsh.module.iot.core.integration.event.clean.CleanOrderCompleteEvent;
+import com.viewsh.module.iot.dal.dataobject.integration.clean.BeaconPresenceConfig;
+import com.viewsh.module.iot.dal.redis.clean.BadgeDeviceStatusRedisDAO;
+import com.viewsh.module.iot.dal.redis.clean.BeaconArrivedTimeRedisDAO;
+import com.viewsh.module.iot.dal.redis.clean.BeaconRssiWindowRedisDAO;
+import com.viewsh.module.iot.dal.redis.clean.SignalLossRedisDAO;
+import com.viewsh.module.iot.service.integration.clean.CleanOrderIntegrationConfigService;
+import com.viewsh.module.iot.service.device.IotDeviceService;
+import com.viewsh.module.iot.dal.dataobject.device.IotDeviceDO;
+import com.xxl.job.core.handler.annotation.XxlJob;
+import jakarta.annotation.Resource;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.rocketmq.spring.core.RocketMQTemplate;
+import com.viewsh.framework.tenant.core.util.TenantUtils;
+import org.springframework.messaging.support.MessageBuilder;
+import org.springframework.stereotype.Component;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Set;
+import org.springframework.data.redis.core.StringRedisTemplate;
+
+/**
+ * 信号丢失规则处理器
+ *
+ * 定时检查离岗后N分钟则触发工单自动完成
+ * 包含作业时长有效性校验,防止"打卡即走"作弊
+ *
+ * @author AI
+ */
+@Component
+@Slf4j
+public class SignalLossRuleProcessor {
+
+ @Resource
+ private SignalLossRedisDAO signalLossRedisDAO;
+
+ @Resource
+ private BeaconArrivedTimeRedisDAO arrivedTimeRedisDAO;
+
+ @Resource
+ private BeaconRssiWindowRedisDAO windowRedisDAO;
+
+ @Resource
+ private BadgeDeviceStatusRedisDAO badgeDeviceStatusRedisDAO;
+
+ @Resource
+ private CleanOrderIntegrationConfigService configService;
+
+ @Resource
+ private IotDeviceService deviceService;
+
+ @Resource
+ private RocketMQTemplate rocketMQTemplate;
+
+ @Resource
+ private StringRedisTemplate stringRedisTemplate;
+
+ /**
+ * Redis Key 模式:扫描所有离岗记录
+ */
+ private static final String LOSS_KEY_PATTERN = "iot:clean:signal:loss:*";
+
+ /**
+ * 定时检查离岗超时(每 30 秒执行一次)
+ *
+ * 遍历所有离岗记录,检查是否超过超时时间
+ * 如果超过,则触发工单完成
+ */
+ @XxlJob("signalLossCheckJob")
+ public String checkLossTimeout() {
+ // TODO: 设置租户上下文(单租户场景使用固定租户ID=1)
+ // 确保后续发送的 RocketMQ 消息正确携带租户信息
+ return doCheckLossTimeout();
+ }
+
+ /**
+ * 执行离岗超时检查
+ */
+ private String doCheckLossTimeout() {
+ try {
+ log.debug("[SignalLoss] 开始检查离岗超时");
+
+ // 1. 扫描所有离岗记录的 Key
+ Set keys = stringRedisTemplate.keys(LOSS_KEY_PATTERN);
+
+ if (keys == null || keys.isEmpty()) {
+ return "暂无";
+ }
+
+ log.debug("[SignalLoss] 发现 {} 条离岗记录", keys.size());
+
+ // 2. 遍历每条记录
+ for (String key : keys) {
+ try {
+ // 解析 deviceId 和 areaId
+ // Key 格式:iot:clean:signal:loss:{deviceId}:{areaId}
+ String[] parts = key.split(":");
+ if (parts.length < 6) {
+ continue;
+ }
+
+ Long deviceId = Long.parseLong(parts[4]);
+ Long areaId = Long.parseLong(parts[5]);
+
+ // 检查超时
+ IotDeviceDO device = deviceService.getDevice(deviceId);
+ if (device == null || device.getTenantId() == null) {
+ log.warn("[SignalLoss] 璁惧涓嶅瓨鍦ㄦ垨缂哄皯绉熸埛淇℃伅: deviceId={}", deviceId);
+ continue;
+ }
+ TenantUtils.execute(device.getTenantId(), () -> checkTimeoutForDevice(deviceId, areaId));
+
+ } catch (Exception e) {
+ log.error("[SignalLoss] 处理离岗记录失败:key={}", key, e);
+ }
+ }
+
+ } catch (Exception e) {
+ log.error("[SignalLoss] 检查离岗超时失败", e);
+ return "ERROR: " + e.getMessage();
+ }
+
+ return "SUCCESS";
+ }
+
+ /**
+ * 检查单个设备的离岗超时
+ */
+ private void checkTimeoutForDevice(Long deviceId, Long areaId) {
+ // P0 插队校验:检查当前工单是否属于正在检查的区域
+ if (isSwitchingOrder(deviceId, areaId)) {
+ log.debug("[SignalLoss][P0Interrupt] 检测到工单切换,跳过区域 {} 的超时检查",
+ areaId);
+ // 清理该区域的离岗记录(避免内存泄漏)
+ signalLossRedisDAO.clearLossRecord(deviceId, areaId);
+ return;
+ }
+
+ // 1. 获取该区域的信标配置(从 BEACON 类型的设备获取)
+ CleanOrderIntegrationConfigService.AreaDeviceConfigWrapper beaconConfigWrapper = configService
+ .getConfigByAreaIdAndRelationType(areaId, "BEACON");
+
+ if (beaconConfigWrapper == null || beaconConfigWrapper.getConfig() == null ||
+ beaconConfigWrapper.getConfig().getBeaconPresence() == null) {
+ log.debug("[SignalLoss] 区域无信标配置:areaId={}", areaId);
+ return;
+ }
+
+ BeaconPresenceConfig.ExitConfig exitConfig = beaconConfigWrapper.getConfig().getBeaconPresence().getExit();
+
+ // 2. 获取 deviceKey(从 IoT 设备缓存获取 serialNumber)
+ String badgeDeviceKey = getDeviceKey(deviceId);
+
+ // 3. 获取首次丢失时间
+ Long firstLossTime = signalLossRedisDAO.getFirstLossTime(deviceId, areaId);
+
+ if (firstLossTime == null) {
+ return;
+ }
+
+ // 3. 获取最后丢失时间
+ Long lastLossTime = signalLossRedisDAO.getLastLossTime(deviceId, areaId);
+
+ if (lastLossTime == null) {
+ return;
+ }
+
+ // 4. 检查是否超时
+ long timeoutMillis = exitConfig.getLossTimeoutMinutes() * 60000L;
+ long elapsedMillis = System.currentTimeMillis() - firstLossTime;
+
+ if (elapsedMillis < timeoutMillis) {
+ log.debug("[SignalLoss] 未超时:deviceId={}, elapsed={}ms, timeout={}ms",
+ deviceId, elapsedMillis, timeoutMillis);
+ return;
+ }
+
+ log.info("[SignalLoss] 检测到离岗超时:deviceId={}, areaId={}, elapsed={}ms",
+ deviceId, areaId, elapsedMillis);
+
+ // 5. 有效性校验:检查作业时长
+ Long arrivedAt = arrivedTimeRedisDAO.getArrivedTime(deviceId, areaId);
+
+ if (arrivedAt == null) {
+ log.warn("[SignalLoss] 未找到到达时间记录:deviceId={}, areaId={}", deviceId, areaId);
+ signalLossRedisDAO.clearLossRecord(deviceId, areaId);
+ return;
+ }
+
+ long durationMs = lastLossTime - arrivedAt;
+ long minValidWorkMillis = exitConfig.getMinValidWorkMinutes() * 60000L;
+
+ // 6. 分支处理:有效 vs 无效作业
+ // TODO 暂时取消作业时长不足抑制自动完成的逻辑,所有情况均触发完成
+ // if (durationMs < minValidWorkMillis) {
+ // // 作业时长不足,抑制完成
+ // handleInvalidWork(deviceId, badgeDeviceKey, areaId,
+ // durationMs, minValidWorkMillis, exitConfig);
+ // } else {
+ // 作业时长有效,触发完成
+ handleTimeoutComplete(deviceId, badgeDeviceKey, areaId,
+ durationMs, lastLossTime);
+ // }
+ }
+
+ /**
+ * 处理无效作业(时长不足)
+ */
+ private void handleInvalidWork(Long deviceId, String deviceKey, Long areaId,
+ Long durationMs, Long minValidWorkMillis,
+ BeaconPresenceConfig.ExitConfig exitConfig) {
+ log.warn("[SignalLoss] 作业时长不足,抑制自动完成:deviceId={}, duration={}ms, minRequired={}ms",
+ deviceId, durationMs, minValidWorkMillis);
+
+ // 1. 发送 TTS 警告
+ publishTtsEvent(deviceId, "工单作业时长异常,请回到作业区域继续完成");
+
+ // 2. 发布审计日志
+ Map data = new HashMap<>();
+ data.put("durationMs", durationMs);
+ data.put("minValidWorkMinutes", exitConfig.getMinValidWorkMinutes());
+ data.put("shortageMs", minValidWorkMillis - durationMs);
+
+ // 获取当前工单ID
+ BadgeDeviceStatusRedisDAO.OrderInfo currentOrder = badgeDeviceStatusRedisDAO.getCurrentOrder(deviceId);
+ Long orderId = currentOrder != null ? currentOrder.getOrderId() : null;
+
+ publishAuditEvent("COMPLETE_SUPPRESSED_INVALID", deviceId, deviceKey, areaId, orderId,
+ "作业时长不足,抑制自动完成", data);
+
+ // 3. 清除丢失记录(允许重新进入)
+ signalLossRedisDAO.clearLossRecord(deviceId, areaId);
+
+ // 4. 清除无效作业标记(允许下次警告)
+ signalLossRedisDAO.markInvalidWorkNotified(deviceId, areaId);
+ }
+
+ /**
+ * 处理超时自动完成
+ */
+ private void handleTimeoutComplete(Long deviceId, String deviceKey, Long areaId,
+ Long durationMs, Long lastLossTime) {
+ log.info("[SignalLoss] 触发自动完成:deviceId={}, areaId={}, duration={}ms",
+ deviceId, areaId, durationMs);
+
+ // 1. 获取当前工单
+ BadgeDeviceStatusRedisDAO.OrderInfo currentOrder = badgeDeviceStatusRedisDAO.getCurrentOrder(deviceId);
+
+ if (currentOrder == null) {
+ log.warn("[SignalLoss] 设备无当前工单:deviceId={}", deviceId);
+ return;
+ }
+
+ // 2. 构建触发数据
+ Map triggerData = new HashMap<>();
+ triggerData.put("durationMs", durationMs);
+ triggerData.put("lastLossTime", lastLossTime);
+ triggerData.put("completionReason", "SIGNAL_LOSS_TIMEOUT");
+
+ // 3. 发布完成事件
+ try {
+ CleanOrderCompleteEvent event = CleanOrderCompleteEvent.builder()
+ .tenantId(TenantContextHolder.getTenantId())
+ .projectId(ProjectContextHolder.getProjectId())
+ .eventId(java.util.UUID.randomUUID().toString())
+ .orderType("CLEAN")
+ .orderId(currentOrder.getOrderId())
+ .deviceId(deviceId)
+ .deviceKey(deviceKey)
+ .areaId(areaId)
+ .triggerSource("IOT_SIGNAL_LOSS")
+ .triggerData(triggerData)
+ .build();
+
+ rocketMQTemplate.syncSend(CleanOrderTopics.ORDER_COMPLETE, MessageBuilder.withPayload(event).build());
+
+ log.info("[SignalLoss] 发布完成事件:eventId={}, orderId={}, duration={}ms",
+ event.getEventId(), currentOrder.getOrderId(), durationMs);
+ } catch (Exception e) {
+ log.error("[SignalLoss] 发布完成事件失败:deviceId={}, orderId={}",
+ deviceId, currentOrder.getOrderId(), e);
+ return;
+ }
+
+ // 4. 发布审计日志
+ Map auditData = new HashMap<>();
+ auditData.put("durationMs", durationMs);
+ auditData.put("lastLossTime", lastLossTime);
+
+ publishAuditEvent("BEACON_COMPLETE_REQUESTED", deviceId, deviceKey, areaId, currentOrder.getOrderId(),
+ "信号丢失超时自动完成", auditData);
+
+ // 5. 清理 Redis 数据
+ cleanupRedisData(deviceId, areaId);
+ }
+
+ /**
+ * 清理 Redis 数据
+ */
+ private void cleanupRedisData(Long deviceId, Long areaId) {
+ signalLossRedisDAO.clearLossRecord(deviceId, areaId);
+ arrivedTimeRedisDAO.clearArrivedTime(deviceId, areaId);
+ windowRedisDAO.clearWindow(deviceId, areaId);
+
+ log.debug("[SignalLoss] 清理 Redis 数据:deviceId={}, areaId={}", deviceId, areaId);
+ }
+
+ /**
+ * 发布审计事件
+ */
+ private void publishAuditEvent(String auditType, Long deviceId, String deviceKey,
+ Long areaId, Long orderId, String message, Map data) {
+ try {
+ CleanOrderAuditEvent event = CleanOrderAuditEvent.builder()
+ .tenantId(TenantContextHolder.getTenantId())
+ .projectId(ProjectContextHolder.getProjectId())
+ .eventId(java.util.UUID.randomUUID().toString())
+ .auditType(auditType)
+ .deviceId(deviceId)
+ .deviceKey(deviceKey)
+ .areaId(areaId)
+ .orderId(orderId)
+ .message(message)
+ .data(data)
+ .build();
+
+ rocketMQTemplate.syncSend(CleanOrderTopics.ORDER_AUDIT, MessageBuilder.withPayload(event).build());
+
+ log.debug("[SignalLoss] 发布审计事件:auditType={}, deviceId={}, orderId={}", auditType, deviceId, orderId);
+ } catch (Exception e) {
+ log.error("[SignalLoss] 发布审计事件失败:auditType={}, deviceId={}", auditType, deviceId, e);
+ }
+ }
+
+ /**
+ * 发布 TTS 事件
+ */
+ private void publishTtsEvent(Long deviceId, String text) {
+ Map data = new HashMap<>();
+ data.put("tts", text);
+ data.put("timestamp", System.currentTimeMillis());
+
+ publishAuditEvent("TTS_REQUEST", deviceId, null, null, null, text, data);
+ }
+
+ /**
+ * 检查是否正在切换工单(P0 插队场景)
+ *
+ * 如果当前工单的区域ID与正在检查的区域不一致,说明保洁员已切换到其他区域的工单
+ *
+ * @param deviceId 设备ID
+ * @param areaId 正在检查的区域ID
+ * @return true-工单切换场景,false-正常离岗场景
+ */
+ private boolean isSwitchingOrder(Long deviceId, Long areaId) {
+ BadgeDeviceStatusRedisDAO.OrderInfo currentOrder = badgeDeviceStatusRedisDAO.getCurrentOrder(deviceId);
+ return currentOrder != null && !currentOrder.getAreaId().equals(areaId);
+ }
+
+ /**
+ * 获取设备 Key(从 IoT 设备缓存获取 serialNumber)
+ *
+ * deviceKey 在 ops_area_device_relation 表中是冗余字段,
+ * 实际来源是 iot_device.serialNumber
+ *
+ * @param deviceId 设备ID
+ * @return deviceKey(serialNumber),获取失败返回 null
+ */
+ private String getDeviceKey(Long deviceId) {
+ try {
+ IotDeviceDO device = deviceService.getDeviceFromCache(deviceId);
+ if (device != null) {
+ return device.getSerialNumber();
+ }
+ } catch (Exception e) {
+ log.warn("[SignalLoss] 获取 deviceKey 失败:deviceId={}", deviceId, e);
+ }
+ return null;
+ }
+}
diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/rule/clean/processor/TrafficThresholdRuleProcessor.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/rule/clean/processor/TrafficThresholdRuleProcessor.java
index a068cdda..01b017da 100644
--- a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/rule/clean/processor/TrafficThresholdRuleProcessor.java
+++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/rule/clean/processor/TrafficThresholdRuleProcessor.java
@@ -1,260 +1,262 @@
-package com.viewsh.module.iot.service.rule.clean.processor;
-
-import com.viewsh.framework.tenant.core.context.TenantContextHolder;
-import com.viewsh.module.iot.core.integration.constants.CleanOrderTopics;
-import com.viewsh.module.iot.core.integration.event.clean.CleanOrderCreateEvent;
-import com.viewsh.module.iot.dal.dataobject.integration.clean.TrafficThresholdConfig;
-import com.viewsh.module.iot.dal.redis.clean.TrafficCounterRedisDAO;
-import com.viewsh.module.iot.service.integration.clean.CleanOrderIntegrationConfigService;
-import jakarta.annotation.Resource;
-import lombok.extern.slf4j.Slf4j;
-import org.apache.rocketmq.spring.core.RocketMQTemplate;
-import org.springframework.data.redis.core.StringRedisTemplate;
-import org.springframework.messaging.support.MessageBuilder;
-import org.springframework.stereotype.Component;
-
-import java.time.LocalDate;
-import java.util.HashMap;
-import java.util.Map;
-import java.util.concurrent.TimeUnit;
-
-/**
- * 客流阈值规则处理器
- *
- * 监听设备属性上报,将增量原子累加到 Redis 阈值计数器,
- * 达到阈值后触发工单创建事件并重置计数器。
- *
- * 同时维护当日累积统计(不因工单触发而重置),用于统计报表。
- *
- * @author AI
- */
-@Component
-@Slf4j
-public class TrafficThresholdRuleProcessor {
-
- @Resource
- private CleanOrderIntegrationConfigService configService;
-
- @Resource
- private TrafficCounterRedisDAO trafficCounterRedisDAO;
-
- @Resource
- private RocketMQTemplate rocketMQTemplate;
-
- @Resource
- private StringRedisTemplate stringRedisTemplate;
-
- /**
- * 处理客流属性上报
- *
- * 支持 people_in 和 people_out 两个属性:
- * - people_in:累加到当日统计 + 阈值计数器(需配置)
- * - people_out:累加到当日统计
- *
- * 支持两种上报模式(通过 configData.trafficThreshold.reportMode 配置):
- * - INCREMENTAL(默认):上报值直接作为增量
- * - CUMULATIVE:上报值为累计值,自动计算差值得到增量
- *
- * @param deviceId 设备ID
- * @param identifier 属性标识符(people_in 或 people_out)
- * @param propertyValue 属性值(增量或累计值,取决于 reportMode)
- */
- public void processPropertyChange(Long deviceId, String identifier, Object propertyValue) {
- // 1. 校验属性类型
- if (!"people_in".equals(identifier) && !"people_out".equals(identifier)) {
- return;
- }
-
- log.debug("[TrafficThreshold] 收到客流属性:deviceId={}, identifier={}, value={}",
- deviceId, identifier, propertyValue);
-
- // 2. 解析原始值
- Long rawValue = parseTrafficCount(propertyValue);
- if (rawValue == null || rawValue <= 0) {
- return;
- }
-
- // 3. 获取配置,判断上报模式
- CleanOrderIntegrationConfigService.AreaDeviceConfigWrapper configWrapper = getConfigWrapper(deviceId);
- TrafficThresholdConfig thresholdConfig = resolveThresholdConfig(configWrapper);
-
- // 4. 根据上报模式计算增量
- long increment;
- if (thresholdConfig != null && thresholdConfig.isCumulative()) {
- increment = resolveIncrement(deviceId, identifier, rawValue);
- } else {
- increment = rawValue;
- }
- if (increment <= 0) {
- return;
- }
-
- // 5. 累加到当日统计(统计与工单触发解耦)
- LocalDate today = LocalDate.now();
- if ("people_in".equals(identifier)) {
- trafficCounterRedisDAO.incrementDaily(deviceId, today, increment, 0);
- } else {
- trafficCounterRedisDAO.incrementDaily(deviceId, today, 0, increment);
- }
- log.debug("[TrafficThreshold] 当日统计累加:deviceId={}, identifier={}, increment={}",
- deviceId, identifier, increment);
-
- // 6. 以下为工单触发逻辑,仅 people_in 参与
- if (!"people_in".equals(identifier)) {
- return;
- }
- if (thresholdConfig == null || !Boolean.TRUE.equals(thresholdConfig.getAutoCreateOrder())) {
- return;
- }
-
- Long areaId = configWrapper.getAreaId();
- handlePeopleIn(deviceId, areaId, increment, today, thresholdConfig, configWrapper);
- }
-
- /**
- * 从配置包装器中提取客流阈值配置
- *
- * @return 阈值配置,无配置时返回 null
- */
- private TrafficThresholdConfig resolveThresholdConfig(
- CleanOrderIntegrationConfigService.AreaDeviceConfigWrapper configWrapper) {
- if (configWrapper == null || configWrapper.getConfig() == null) {
- return null;
- }
- return configWrapper.getConfig().getTrafficThreshold();
- }
-
- /**
- * 累计值转增量
- *
- * 通过 Redis 存储上次上报的累计值,计算差值得到本次增量。
- * 处理三种场景:首次上报、正常递增、设备重启归零。
- *
- * @param deviceId 设备ID
- * @param identifier 属性标识符
- * @param currentValue 本次上报的累计值
- * @return 增量值;首次上报返回 0
- */
- private long resolveIncrement(Long deviceId, String identifier, long currentValue) {
- Long lastValue = trafficCounterRedisDAO.getLastCumulativeValue(deviceId, identifier);
-
- // 无论是否能算出增量,都记录当前值
- trafficCounterRedisDAO.setLastCumulativeValue(deviceId, identifier, currentValue);
-
- if (lastValue == null) {
- // 首次上报:无历史基准,不计入统计
- log.info("[TrafficThreshold] 累计值设备首次上报,建立基准:deviceId={}, identifier={}, value={}",
- deviceId, identifier, currentValue);
- return 0;
- }
-
- if (currentValue >= lastValue) {
- return currentValue - lastValue;
- }
-
- // currentValue < lastValue → 设备重启归零
- log.info("[TrafficThreshold] 检测到设备重启:deviceId={}, identifier={}, last={}, current={}",
- deviceId, identifier, lastValue, currentValue);
- return currentValue;
- }
-
- /**
- * 处理 people_in 增量
- */
- private void handlePeopleIn(Long deviceId, Long areaId, long increment, LocalDate today,
- TrafficThresholdConfig thresholdConfig,
- CleanOrderIntegrationConfigService.AreaDeviceConfigWrapper configWrapper) {
- // 1. 原子累加到阈值计数器,返回累积值(当日统计已在 processPropertyChange 中完成)
- Long accumulated = trafficCounterRedisDAO.incrementThreshold(deviceId, areaId, increment);
-
- log.debug("[TrafficThreshold] people_in 阈值累加:deviceId={}, areaId={}, increment={}, accumulated={}, threshold={}",
- deviceId, areaId, increment, accumulated, thresholdConfig.getThreshold());
-
- // 3. 阈值判定
- if (accumulated < thresholdConfig.getThreshold()) {
- return; // 未达标
- }
-
- // 4. 防重复检查(使用 Redis 分布式锁)
- String lockKey = String.format("iot:clean:traffic:lock:%s:%s", deviceId, areaId);
- Boolean locked = stringRedisTemplate.opsForValue()
- .setIfAbsent(lockKey, "1", thresholdConfig.getTimeWindowSeconds(), TimeUnit.SECONDS);
-
- if (Boolean.FALSE.equals(locked)) {
- log.info("[TrafficThreshold] 防重复触发:deviceId={}, areaId={}", deviceId, areaId);
- return;
- }
-
- // 5. 发布工单创建事件
- // 注意:阈值计数器将在 Ops 模块工单创建成功后重置,确保事务一致性
- publishCreateEvent(configWrapper, accumulated, thresholdConfig.getThreshold());
- }
-
- /**
- * 发布工单创建事件
- */
- private void publishCreateEvent(CleanOrderIntegrationConfigService.AreaDeviceConfigWrapper configWrapper,
- Long accumulated, Integer threshold) {
- try {
- CleanOrderCreateEvent event = CleanOrderCreateEvent.builder()
- .tenantId(TenantContextHolder.getTenantId())
- .orderType("CLEAN")
- .areaId(configWrapper.getAreaId())
- .triggerSource("IOT_TRAFFIC")
- .triggerDeviceId(configWrapper.getDeviceId())
- .triggerDeviceKey(configWrapper.getDeviceKey())
- .priority(configWrapper.getConfig().getTrafficThreshold().getOrderPriority())
- .triggerData(buildTriggerData(accumulated, threshold))
- .build();
-
- rocketMQTemplate.syncSend(CleanOrderTopics.ORDER_CREATE, MessageBuilder.withPayload(event).build());
-
- log.info("[TrafficThreshold] 发布工单创建事件:eventId={}, areaId={}, accumulated={}, threshold={}",
- event.getEventId(), configWrapper.getAreaId(), accumulated, threshold);
- } catch (Exception e) {
- log.error("[TrafficThreshold] 发布工单创建事件失败:deviceId={}, areaId={}",
- configWrapper.getDeviceId(), configWrapper.getAreaId(), e);
- }
- }
-
- /**
- * 构建触发数据
- */
- private Map buildTriggerData(Long accumulated, Integer threshold) {
- Map data = new HashMap<>();
- data.put("accumulated", accumulated);
- data.put("threshold", threshold);
- data.put("exceededCount", accumulated - threshold);
- return data;
- }
-
- /**
- * 获取配置包装器
- */
- private CleanOrderIntegrationConfigService.AreaDeviceConfigWrapper getConfigWrapper(Long deviceId) {
- return configService.getConfigWrapperByDeviceId(deviceId);
- }
-
- /**
- * 解析客流计数值
- */
- private Long parseTrafficCount(Object value) {
- if (value == null) {
- return null;
- }
-
- if (value instanceof Number) {
- return ((Number) value).longValue();
- }
-
- if (value instanceof String) {
- try {
- return Long.parseLong((String) value);
- } catch (NumberFormatException e) {
- return null;
- }
- }
-
- return null;
- }
-}
+package com.viewsh.module.iot.service.rule.clean.processor;
+
+import com.viewsh.framework.tenant.core.context.TenantContextHolder;
+import com.viewsh.framework.tenant.core.context.ProjectContextHolder;
+import com.viewsh.module.iot.core.integration.constants.CleanOrderTopics;
+import com.viewsh.module.iot.core.integration.event.clean.CleanOrderCreateEvent;
+import com.viewsh.module.iot.dal.dataobject.integration.clean.TrafficThresholdConfig;
+import com.viewsh.module.iot.dal.redis.clean.TrafficCounterRedisDAO;
+import com.viewsh.module.iot.service.integration.clean.CleanOrderIntegrationConfigService;
+import jakarta.annotation.Resource;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.rocketmq.spring.core.RocketMQTemplate;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.messaging.support.MessageBuilder;
+import org.springframework.stereotype.Component;
+
+import java.time.LocalDate;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 客流阈值规则处理器
+ *
+ * 监听设备属性上报,将增量原子累加到 Redis 阈值计数器,
+ * 达到阈值后触发工单创建事件并重置计数器。
+ *
+ * 同时维护当日累积统计(不因工单触发而重置),用于统计报表。
+ *
+ * @author AI
+ */
+@Component
+@Slf4j
+public class TrafficThresholdRuleProcessor {
+
+ @Resource
+ private CleanOrderIntegrationConfigService configService;
+
+ @Resource
+ private TrafficCounterRedisDAO trafficCounterRedisDAO;
+
+ @Resource
+ private RocketMQTemplate rocketMQTemplate;
+
+ @Resource
+ private StringRedisTemplate stringRedisTemplate;
+
+ /**
+ * 处理客流属性上报
+ *
+ * 支持 people_in 和 people_out 两个属性:
+ * - people_in:累加到当日统计 + 阈值计数器(需配置)
+ * - people_out:累加到当日统计
+ *
+ * 支持两种上报模式(通过 configData.trafficThreshold.reportMode 配置):
+ * - INCREMENTAL(默认):上报值直接作为增量
+ * - CUMULATIVE:上报值为累计值,自动计算差值得到增量
+ *
+ * @param deviceId 设备ID
+ * @param identifier 属性标识符(people_in 或 people_out)
+ * @param propertyValue 属性值(增量或累计值,取决于 reportMode)
+ */
+ public void processPropertyChange(Long deviceId, String identifier, Object propertyValue) {
+ // 1. 校验属性类型
+ if (!"people_in".equals(identifier) && !"people_out".equals(identifier)) {
+ return;
+ }
+
+ log.debug("[TrafficThreshold] 收到客流属性:deviceId={}, identifier={}, value={}",
+ deviceId, identifier, propertyValue);
+
+ // 2. 解析原始值
+ Long rawValue = parseTrafficCount(propertyValue);
+ if (rawValue == null || rawValue <= 0) {
+ return;
+ }
+
+ // 3. 获取配置,判断上报模式
+ CleanOrderIntegrationConfigService.AreaDeviceConfigWrapper configWrapper = getConfigWrapper(deviceId);
+ TrafficThresholdConfig thresholdConfig = resolveThresholdConfig(configWrapper);
+
+ // 4. 根据上报模式计算增量
+ long increment;
+ if (thresholdConfig != null && thresholdConfig.isCumulative()) {
+ increment = resolveIncrement(deviceId, identifier, rawValue);
+ } else {
+ increment = rawValue;
+ }
+ if (increment <= 0) {
+ return;
+ }
+
+ // 5. 累加到当日统计(统计与工单触发解耦)
+ LocalDate today = LocalDate.now();
+ if ("people_in".equals(identifier)) {
+ trafficCounterRedisDAO.incrementDaily(deviceId, today, increment, 0);
+ } else {
+ trafficCounterRedisDAO.incrementDaily(deviceId, today, 0, increment);
+ }
+ log.debug("[TrafficThreshold] 当日统计累加:deviceId={}, identifier={}, increment={}",
+ deviceId, identifier, increment);
+
+ // 6. 以下为工单触发逻辑,仅 people_in 参与
+ if (!"people_in".equals(identifier)) {
+ return;
+ }
+ if (thresholdConfig == null || !Boolean.TRUE.equals(thresholdConfig.getAutoCreateOrder())) {
+ return;
+ }
+
+ Long areaId = configWrapper.getAreaId();
+ handlePeopleIn(deviceId, areaId, increment, today, thresholdConfig, configWrapper);
+ }
+
+ /**
+ * 从配置包装器中提取客流阈值配置
+ *
+ * @return 阈值配置,无配置时返回 null
+ */
+ private TrafficThresholdConfig resolveThresholdConfig(
+ CleanOrderIntegrationConfigService.AreaDeviceConfigWrapper configWrapper) {
+ if (configWrapper == null || configWrapper.getConfig() == null) {
+ return null;
+ }
+ return configWrapper.getConfig().getTrafficThreshold();
+ }
+
+ /**
+ * 累计值转增量
+ *
+ * 通过 Redis 存储上次上报的累计值,计算差值得到本次增量。
+ * 处理三种场景:首次上报、正常递增、设备重启归零。
+ *
+ * @param deviceId 设备ID
+ * @param identifier 属性标识符
+ * @param currentValue 本次上报的累计值
+ * @return 增量值;首次上报返回 0
+ */
+ private long resolveIncrement(Long deviceId, String identifier, long currentValue) {
+ Long lastValue = trafficCounterRedisDAO.getLastCumulativeValue(deviceId, identifier);
+
+ // 无论是否能算出增量,都记录当前值
+ trafficCounterRedisDAO.setLastCumulativeValue(deviceId, identifier, currentValue);
+
+ if (lastValue == null) {
+ // 首次上报:无历史基准,不计入统计
+ log.info("[TrafficThreshold] 累计值设备首次上报,建立基准:deviceId={}, identifier={}, value={}",
+ deviceId, identifier, currentValue);
+ return 0;
+ }
+
+ if (currentValue >= lastValue) {
+ return currentValue - lastValue;
+ }
+
+ // currentValue < lastValue → 设备重启归零
+ log.info("[TrafficThreshold] 检测到设备重启:deviceId={}, identifier={}, last={}, current={}",
+ deviceId, identifier, lastValue, currentValue);
+ return currentValue;
+ }
+
+ /**
+ * 处理 people_in 增量
+ */
+ private void handlePeopleIn(Long deviceId, Long areaId, long increment, LocalDate today,
+ TrafficThresholdConfig thresholdConfig,
+ CleanOrderIntegrationConfigService.AreaDeviceConfigWrapper configWrapper) {
+ // 1. 原子累加到阈值计数器,返回累积值(当日统计已在 processPropertyChange 中完成)
+ Long accumulated = trafficCounterRedisDAO.incrementThreshold(deviceId, areaId, increment);
+
+ log.debug("[TrafficThreshold] people_in 阈值累加:deviceId={}, areaId={}, increment={}, accumulated={}, threshold={}",
+ deviceId, areaId, increment, accumulated, thresholdConfig.getThreshold());
+
+ // 3. 阈值判定
+ if (accumulated < thresholdConfig.getThreshold()) {
+ return; // 未达标
+ }
+
+ // 4. 防重复检查(使用 Redis 分布式锁)
+ String lockKey = String.format("iot:clean:traffic:lock:%s:%s", deviceId, areaId);
+ Boolean locked = stringRedisTemplate.opsForValue()
+ .setIfAbsent(lockKey, "1", thresholdConfig.getTimeWindowSeconds(), TimeUnit.SECONDS);
+
+ if (Boolean.FALSE.equals(locked)) {
+ log.info("[TrafficThreshold] 防重复触发:deviceId={}, areaId={}", deviceId, areaId);
+ return;
+ }
+
+ // 5. 发布工单创建事件
+ // 注意:阈值计数器将在 Ops 模块工单创建成功后重置,确保事务一致性
+ publishCreateEvent(configWrapper, accumulated, thresholdConfig.getThreshold());
+ }
+
+ /**
+ * 发布工单创建事件
+ */
+ private void publishCreateEvent(CleanOrderIntegrationConfigService.AreaDeviceConfigWrapper configWrapper,
+ Long accumulated, Integer threshold) {
+ try {
+ CleanOrderCreateEvent event = CleanOrderCreateEvent.builder()
+ .tenantId(TenantContextHolder.getTenantId())
+ .projectId(ProjectContextHolder.getProjectId())
+ .orderType("CLEAN")
+ .areaId(configWrapper.getAreaId())
+ .triggerSource("IOT_TRAFFIC")
+ .triggerDeviceId(configWrapper.getDeviceId())
+ .triggerDeviceKey(configWrapper.getDeviceKey())
+ .priority(configWrapper.getConfig().getTrafficThreshold().getOrderPriority())
+ .triggerData(buildTriggerData(accumulated, threshold))
+ .build();
+
+ rocketMQTemplate.syncSend(CleanOrderTopics.ORDER_CREATE, MessageBuilder.withPayload(event).build());
+
+ log.info("[TrafficThreshold] 发布工单创建事件:eventId={}, areaId={}, accumulated={}, threshold={}",
+ event.getEventId(), configWrapper.getAreaId(), accumulated, threshold);
+ } catch (Exception e) {
+ log.error("[TrafficThreshold] 发布工单创建事件失败:deviceId={}, areaId={}",
+ configWrapper.getDeviceId(), configWrapper.getAreaId(), e);
+ }
+ }
+
+ /**
+ * 构建触发数据
+ */
+ private Map buildTriggerData(Long accumulated, Integer threshold) {
+ Map data = new HashMap<>();
+ data.put("accumulated", accumulated);
+ data.put("threshold", threshold);
+ data.put("exceededCount", accumulated - threshold);
+ return data;
+ }
+
+ /**
+ * 获取配置包装器
+ */
+ private CleanOrderIntegrationConfigService.AreaDeviceConfigWrapper getConfigWrapper(Long deviceId) {
+ return configService.getConfigWrapperByDeviceId(deviceId);
+ }
+
+ /**
+ * 解析客流计数值
+ */
+ private Long parseTrafficCount(Object value) {
+ if (value == null) {
+ return null;
+ }
+
+ if (value instanceof Number) {
+ return ((Number) value).longValue();
+ }
+
+ if (value instanceof String) {
+ try {
+ return Long.parseLong((String) value);
+ } catch (NumberFormatException e) {
+ return null;
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/rule/clean/processor/TrajectoryDetectionProcessor.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/rule/clean/processor/TrajectoryDetectionProcessor.java
index 933e9754..0087b696 100644
--- a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/rule/clean/processor/TrajectoryDetectionProcessor.java
+++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/rule/clean/processor/TrajectoryDetectionProcessor.java
@@ -22,6 +22,7 @@ import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.messaging.support.MessageBuilder;
import org.springframework.stereotype.Component;
+import com.viewsh.framework.tenant.core.context.ProjectContextHolder;
import com.viewsh.framework.tenant.core.context.TenantContextHolder;
import java.time.LocalDateTime;
@@ -449,6 +450,7 @@ public class TrajectoryDetectionProcessor {
.enterRssi(enterRssi)
.eventTime((eventTime != null ? eventTime : LocalDateTime.now()).toString())
.tenantId(TenantContextHolder.getTenantId())
+ .projectId(ProjectContextHolder.getProjectId())
.build();
rocketMQTemplate.syncSend(TrajectoryTopics.TRAJECTORY_ENTER,
@@ -475,6 +477,7 @@ public class TrajectoryDetectionProcessor {
.enterTimestamp(enterTimestamp)
.eventTime((eventTime != null ? eventTime : LocalDateTime.now()).toString())
.tenantId(TenantContextHolder.getTenantId())
+ .projectId(ProjectContextHolder.getProjectId())
.build();
rocketMQTemplate.syncSend(TrajectoryTopics.TRAJECTORY_LEAVE,