From 026a12682428897449454707b7d65122a02cde21 Mon Sep 17 00:00:00 2001 From: lzh Date: Wed, 29 Apr 2026 22:20:35 +0800 Subject: [PATCH] =?UTF-8?q?fix(iot):=20=E4=BC=A0=E9=80=92=E9=9B=86?= =?UTF-8?q?=E6=88=90=E4=BA=8B=E4=BB=B6=E9=A1=B9=E7=9B=AEID?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../integration/event/BaseDeviceEvent.java | 137 +- .../event/clean/CleanOrderArriveEvent.java | 163 +- .../event/clean/CleanOrderAuditEvent.java | 201 +- .../event/clean/CleanOrderCompleteEvent.java | 163 +- .../event/clean/CleanOrderCreateEvent.java | 177 +- .../trajectory/TrajectoryEnterEvent.java | 5 + .../trajectory/TrajectoryLeaveEvent.java | 5 + .../service/device/IotDeviceServiceImpl.java | 1642 +++++++++-------- .../BeaconDetectionRuleProcessor.java | 755 ++++---- .../processor/ButtonEventRuleProcessor.java | 597 +++--- .../processor/SignalLossRuleProcessor.java | 771 ++++---- .../TrafficThresholdRuleProcessor.java | 522 +++--- .../TrajectoryDetectionProcessor.java | 3 + 13 files changed, 2596 insertions(+), 2545 deletions(-) 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)由以下路径清理: - *

- * - * @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)由以下路径清理: + *

+ * + * @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,