From 471cd45162e95e656f8078a612267216fd67e401 Mon Sep 17 00:00:00 2001 From: lzh Date: Sat, 17 Jan 2026 15:20:57 +0800 Subject: [PATCH] =?UTF-8?q?feat(iot):=20add-iot-clean-order-integration?= =?UTF-8?q?=E9=98=B6=E6=AE=B5=E4=B8=80-=E5=9F=BA=E7=A1=80=E8=AE=BE?= =?UTF-8?q?=E6=96=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../clean/BeaconPresenceConfig.java | 165 ++++++++++++++++ .../integration/clean/ButtonEventConfig.java | 33 ++++ .../clean/CleanOrderIntegrationConfig.java | 36 ++++ .../clean/OpsAreaDeviceRelationDO.java | 92 +++++++++ .../clean/TrafficThresholdConfig.java | 45 +++++ .../clean/OpsAreaDeviceRelationMapper.java | 71 +++++++ .../clean/BeaconArrivedTimeRedisDAO.java | 83 ++++++++ .../redis/clean/BeaconRssiWindowRedisDAO.java | 112 +++++++++++ .../clean/DeviceCurrentOrderRedisDAO.java | 178 ++++++++++++++++++ .../dal/redis/clean/SignalLossRedisDAO.java | 168 +++++++++++++++++ .../clean/TrafficCounterBaseRedisDAO.java | 93 +++++++++ .../CleanOrderIntegrationConfigService.java | 175 +++++++++++++++++ ...leanOrderIntegrationConfigServiceImpl.java | 152 +++++++++++++++ 13 files changed, 1403 insertions(+) create mode 100644 viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/dataobject/integration/clean/BeaconPresenceConfig.java create mode 100644 viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/dataobject/integration/clean/ButtonEventConfig.java create mode 100644 viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/dataobject/integration/clean/CleanOrderIntegrationConfig.java create mode 100644 viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/dataobject/integration/clean/OpsAreaDeviceRelationDO.java create mode 100644 viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/dataobject/integration/clean/TrafficThresholdConfig.java create mode 100644 viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/mysql/integration/clean/OpsAreaDeviceRelationMapper.java create mode 100644 viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/redis/clean/BeaconArrivedTimeRedisDAO.java create mode 100644 viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/redis/clean/BeaconRssiWindowRedisDAO.java create mode 100644 viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/redis/clean/DeviceCurrentOrderRedisDAO.java create mode 100644 viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/redis/clean/SignalLossRedisDAO.java create mode 100644 viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/redis/clean/TrafficCounterBaseRedisDAO.java create mode 100644 viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/integration/clean/CleanOrderIntegrationConfigService.java create mode 100644 viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/integration/clean/CleanOrderIntegrationConfigServiceImpl.java diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/dataobject/integration/clean/BeaconPresenceConfig.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/dataobject/integration/clean/BeaconPresenceConfig.java new file mode 100644 index 0000000..94044d8 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/dataobject/integration/clean/BeaconPresenceConfig.java @@ -0,0 +1,165 @@ +package com.viewsh.module.iot.dal.dataobject.integration.clean; + +import lombok.Data; + +/** + * 蓝牙信标检测配置 + *

+ * 用于配置基于工牌蓝牙信标检测自动确认到岗/离岗的规则 + * 采用"强进弱出"双阈值算法,避免信号抖动导致的误判 + * + * @author AI + */ +@Data +public class BeaconPresenceConfig { + + /** + * 是否启用信标检测 + */ + private Boolean enabled; + + /** + * 目标信标 MAC 地址 + *

+ * 例如:F0:C8:60:1D:10:BB + */ + private String beaconMac; + + /** + * ��动窗口配置 + */ + private WindowConfig window; + + /** + * 进入(到岗)判定配置 - 强阈值 + */ + private EnterConfig enter; + + /** + * 退出(离岗)判定配置 - 弱阈值 + */ + private ExitConfig exit; + + /** + * 滑动窗口配置 + */ + @Data + public static class WindowConfig { + + /** + * 样本在 Redis 中的保留时间(秒) + *

+ * 超过此时间的样本将被自动清理,防止 Redis 内存溢出 + */ + private Integer sampleTtlSeconds; + + /** + * 未检测到信标时的默认填充值 + *

+ * 当工牌未检测到目标信标时,使用此值填充窗口 + * 建议设置为 -999(明显低于正常 RSSI 值) + */ + private Integer missingValue; + } + + /** + * 进入(到岗)判定配置 - 强阈值 + *

+ * 使用强阈值(如 -70dBm)可以避免路过误判,只有信号足够强才算到达 + */ + @Data + public static class EnterConfig { + + /** + * RSSI 强阈值(dBm) + *

+ * 只有 RSSI >= 此值才算有效信号 + * 例如:-70 + */ + private Integer rssiThreshold; + + /** + * 滑动窗口大小 + *

+ * 采样窗口中的样本数量 + * 例如:3 表示取最近 3 次采样 + */ + private Integer windowSize; + + /** + * 命中次数 + *

+ * 窗口中满足阈值的次数达到此值时,判定为到达 + * 例如:2 表示 3 次中有 2 次满足即判定到达 + */ + private Integer hitCount; + + /** + * 是否自动触发到岗事件 + *

+ * true = 检测到到达后自动发布 ops.order.arrive 事件 + * false = 仅记录日志,不触发事件 + */ + private Boolean autoArrival; + } + + /** + * 退出(离岗)判定配置 - 弱阈值 + *

+ * 使用弱阈值(如 -85dBm)和迟滞设计,避免边缘抖动 + * 只有信号足够弱或彻底消失才算离开 + */ + @Data + public static class ExitConfig { + + /** + * RSSI 弱阈值(dBm) + *

+ * RSSI < 此值或信号丢失才算离开 + * 例如:-85 + */ + private Integer weakRssiThreshold; + + /** + * 滑动窗口大小 + */ + private Integer windowSize; + + /** + * 命中次数 + *

+ * 窗口中满足弱阈值(丢失)的次数达到此值时,判定为离开 + */ + private Integer hitCount; + + /** + * 确认离开后多久发送警告(分钟) + *

+ * 0 = 立即发送 + */ + private Integer warningDelayMinutes; + + /** + * 持续丢失多久后触发自动完成(分钟) + *

+ * 从首次丢失开始计时,持续丢失超过此时间后自动完成工单 + */ + private Integer lossTimeoutMinutes; + + /** + * 最小有效作业时长(分钟) + *

+ * 用于防止"打卡即走"作弊 + * 如果作业时长(从到岗到离开)小于此值,则抑制自动完成 + */ + private Integer minValidWorkMinutes; + + /** + * 是否自动触发完成事件 + *

+ * true = 检测到离开超时后自动发布 ops.order.complete 事件 + * false = 仅记录日志,不触发事件 + */ + private Boolean autoComplete; + } +} diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/dataobject/integration/clean/ButtonEventConfig.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/dataobject/integration/clean/ButtonEventConfig.java new file mode 100644 index 0000000..04838d2 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/dataobject/integration/clean/ButtonEventConfig.java @@ -0,0 +1,33 @@ +package com.viewsh.module.iot.dal.dataobject.integration.clean; + +import lombok.Data; + +/** + * 按键事件配置 + *

+ * 用于配置工牌按键事件的处理规则 + * + * @author AI + */ +@Data +public class ButtonEventConfig { + + /** + * 是否启用按键事件处理 + */ + private Boolean enabled; + + /** + * 确认键 ID + *

+ * 当保洁员按下此按键时,触发工单确认事件 + */ + private Integer confirmKeyId; + + /** + * 查询键 ID + *

+ * 当保洁员按下此按键时,触发工单查询事件 + */ + private Integer queryKeyId; +} diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/dataobject/integration/clean/CleanOrderIntegrationConfig.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/dataobject/integration/clean/CleanOrderIntegrationConfig.java new file mode 100644 index 0000000..741060d --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/dataobject/integration/clean/CleanOrderIntegrationConfig.java @@ -0,0 +1,36 @@ +package com.viewsh.module.iot.dal.dataobject.integration.clean; + +import lombok.Data; + +/** + * 保洁工单集成配置 + *

+ * 这是 IoT 设备与保洁工单集成的总配置类,包含所有子配置 + * 存储在 {@link OpsAreaDeviceRelationDO#getConfigData()} 中 + * + * @author AI + */ +@Data +public class CleanOrderIntegrationConfig { + + /** + * 客流阈值配置 + *

+ * 用于基于客流计数器自动触发工单创建 + */ + private TrafficThresholdConfig trafficThreshold; + + /** + * 蓝牙信标检测配置 + *

+ * 用于基于工牌蓝牙信标检测自动确认到岗/离岗 + */ + private BeaconPresenceConfig beaconPresence; + + /** + * 按键事件配置 + *

+ * 用于工牌按键事件的处理 + */ + private ButtonEventConfig buttonEvent; +} diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/dataobject/integration/clean/OpsAreaDeviceRelationDO.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/dataobject/integration/clean/OpsAreaDeviceRelationDO.java new file mode 100644 index 0000000..e2490cb --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/dataobject/integration/clean/OpsAreaDeviceRelationDO.java @@ -0,0 +1,92 @@ +package com.viewsh.module.iot.dal.dataobject.integration.clean; + +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; +import com.viewsh.framework.mybatis.core.dataobject.BaseDO; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 运营区域设备关联 DO + *

+ * 用于建立运营区域与 IoT 设备的绑定关系,并存储核心检测配置 + * 注意:此表在 ops 库中创建,但在 IoT 模块中��问(通过 Feign 或直接访问) + * + * @author AI + */ +@TableName(value = "ops_area_device_relation", autoResultMap = true) +@KeySequence("ops_area_device_relation_seq") +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class OpsAreaDeviceRelationDO extends BaseDO { + + /** + * 主键 + */ + @TableId + private Long id; + + /** + * 运营区域ID + *

+ * 关联 ops_bus_area.id + */ + private Long areaId; + + /** + * IoT设备ID + *

+ * 关联 iot_device.id + */ + private Long deviceId; + + /** + * 设备Key(冗余字段,便于查询) + */ + private String deviceKey; + + /** + * 产品ID + *

+ * 关联 iot_product.id + */ + private Long productId; + + /** + * 产品Key(冗余字段,便于查询) + */ + private String productKey; + + /** + * 关联类型 + *

+ * TRAFFIC_COUNTER - 客流计数器 + * BEACON - 蓝牙信标 + * BADGE - 工牌设备 + */ + private String relationType; + + /** + * 配置数据(JSON格式) + *

+ * 存储保洁工单集成的各类配置 + * 使用 {@link JacksonTypeHandler} 自动序列化/反序列化 + */ + @TableField(typeHandler = JacksonTypeHandler.class) + private CleanOrderIntegrationConfig configData; + + /** + * 是否启用 + *

+ * true - 启用 + * false - 禁用 + */ + private Boolean enabled; +} diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/dataobject/integration/clean/TrafficThresholdConfig.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/dataobject/integration/clean/TrafficThresholdConfig.java new file mode 100644 index 0000000..54f56d4 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/dataobject/integration/clean/TrafficThresholdConfig.java @@ -0,0 +1,45 @@ +package com.viewsh.module.iot.dal.dataobject.integration.clean; + +import lombok.Data; + +/** + * 客流阈值配置 + *

+ * 用于配置基于客流计数器自动触发工单创建的规则 + * + * @author AI + */ +@Data +public class TrafficThresholdConfig { + + /** + * 触发阈值 + *

+ * 当实际客流(当前值 - 基准值)达到此阈值时,触发工单创建 + */ + private Integer threshold; + + /** + * 统计时间窗口(秒) + *

+ * 在此时间窗口内,同一设备只能触发一次工单创建,防止重复 + */ + private Integer timeWindowSeconds; + + /** + * 是否自动创建工单 + *

+ * true = 自动创建工单 + * false = 仅记录日志,不创建工单 + */ + private Boolean autoCreateOrder; + + /** + * 工单优先级 + *

+ * P0 = 紧急 + * P1 = 重要 + * P2 = 普通 + */ + private String orderPriority; +} diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/mysql/integration/clean/OpsAreaDeviceRelationMapper.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/mysql/integration/clean/OpsAreaDeviceRelationMapper.java new file mode 100644 index 0000000..1acae0d --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/mysql/integration/clean/OpsAreaDeviceRelationMapper.java @@ -0,0 +1,71 @@ +package com.viewsh.module.iot.dal.mysql.integration.clean; + +import com.viewsh.framework.common.pojo.PageResult; +import com.viewsh.framework.mybatis.core.mapper.BaseMapperX; +import com.viewsh.framework.mybatis.core.query.LambdaQueryWrapperX; +import com.viewsh.module.iot.dal.dataobject.integration.clean.OpsAreaDeviceRelationDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; + +/** + * 运营区域设备关联 Mapper + * + * @author AI + */ +@Mapper +public interface OpsAreaDeviceRelationMapper extends BaseMapperX { + + /** + * 根据设备ID查询关联关系(仅查询启用的) + * + * @param deviceId 设备ID + * @return 关联关系 + */ + default OpsAreaDeviceRelationDO selectByDeviceId(Long deviceId) { + return selectOne(new LambdaQueryWrapperX() + .eq(OpsAreaDeviceRelationDO::getDeviceId, deviceId) + .eq(OpsAreaDeviceRelationDO::getEnabled, true) + .orderByDesc(OpsAreaDeviceRelationDO::getCreateTime) + .last("LIMIT 1")); + } + + /** + * 根据区域ID查询所有启用的关联关系 + * + * @param areaId 区域ID + * @return 关联关系列表 + */ + default List selectListByAreaId(Long areaId) { + return selectList(new LambdaQueryWrapperX() + .eq(OpsAreaDeviceRelationDO::getAreaId, areaId) + .eq(OpsAreaDeviceRelationDO::getEnabled, true) + .orderByDesc(OpsAreaDeviceRelationDO::getCreateTime)); + } + + /** + * 根据区域ID和关联类型查询关联关系 + * + * @param areaId 区域ID + * @param relationType 关联类型(TRAFFIC_COUNTER/BEACON/BADGE) + * @return 关联关系列表 + */ + default List selectListByAreaIdAndRelationType(Long areaId, String relationType) { + return selectList(new LambdaQueryWrapperX() + .eq(OpsAreaDeviceRelationDO::getAreaId, areaId) + .eq(OpsAreaDeviceRelationDO::getRelationType, relationType) + .eq(OpsAreaDeviceRelationDO::getEnabled, true)); + } + + /** + * 根据产品Key查询所有关联关系 + * + * @param productKey 产品Key + * @return 关联关系列表 + */ + default List selectListByProductKey(String productKey) { + return selectList(new LambdaQueryWrapperX() + .eq(OpsAreaDeviceRelationDO::getProductKey, productKey) + .eq(OpsAreaDeviceRelationDO::getEnabled, true)); + } +} diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/redis/clean/BeaconArrivedTimeRedisDAO.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/redis/clean/BeaconArrivedTimeRedisDAO.java new file mode 100644 index 0000000..57b3115 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/redis/clean/BeaconArrivedTimeRedisDAO.java @@ -0,0 +1,83 @@ +package com.viewsh.module.iot.dal.redis.clean; + +import jakarta.annotation.Resource; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Repository; + +/** + * 蓝牙信标到达时间 Redis DAO + *

+ * 用于记录保洁员首次进入区域的时间戳 + * 用于计算作业时长(Duration = 离开时间 - 到达时间) + * + * @author AI + */ +@Repository +public class BeaconArrivedTimeRedisDAO { + + /** + * 到达时间 Key 模式 + *

+ * 格式:iot:clean:beacon:arrivedAt:{deviceId}:{areaId} + */ + private static final String ARRIVED_AT_KEY_PATTERN = "iot:clean:beacon:arrivedAt:%s:%s"; + + /** + * 到达时间的 TTL(秒) + *

+ * 默认保留 24 小时 + */ + private static final int ARRIVED_AT_TTL_SECONDS = 86400; + + @Resource + private StringRedisTemplate stringRedisTemplate; + + /** + * 记录到达时间 + * + * @param deviceId 设备ID + * @param areaId 区域ID + * @param timestamp 时间戳(毫秒) + */ + public void recordArrivedTime(Long deviceId, Long areaId, Long timestamp) { + String key = formatKey(deviceId, areaId); + stringRedisTemplate.opsForValue().set(key, String.valueOf(timestamp), + ARRIVED_AT_TTL_SECONDS, java.util.concurrent.TimeUnit.SECONDS); + } + + /** + * 获取到达时间 + * + * @param deviceId 设备ID + * @param areaId 区域ID + * @return 时间戳(毫秒),如果不存在返回 null + */ + public Long getArrivedTime(Long deviceId, Long areaId) { + String key = formatKey(deviceId, areaId); + String timestampStr = stringRedisTemplate.opsForValue().get(key); + + if (timestampStr == null) { + return null; + } + + return Long.parseLong(timestampStr); + } + + /** + * 清除到达时间 + * + * @param deviceId 设备ID + * @param areaId 区域ID + */ + public void clearArrivedTime(Long deviceId, Long areaId) { + String key = formatKey(deviceId, areaId); + stringRedisTemplate.delete(key); + } + + /** + * 格式化 Redis Key + */ + private static String formatKey(Long deviceId, Long areaId) { + return String.format(ARRIVED_AT_KEY_PATTERN, deviceId, areaId); + } +} diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/redis/clean/BeaconRssiWindowRedisDAO.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/redis/clean/BeaconRssiWindowRedisDAO.java new file mode 100644 index 0000000..22aba3f --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/redis/clean/BeaconRssiWindowRedisDAO.java @@ -0,0 +1,112 @@ +package com.viewsh.module.iot.dal.redis.clean; + +import jakarta.annotation.Resource; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Repository; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +/** + * 蓝牙信标 RSSI 滑动窗口 Redis DAO + *

+ * 用于存储工牌检测到的信标 RSSI 值的滑动窗口样本 + * 支持抗抖动检测(基于滑动窗口的"强进弱出"算法) + * + * @author AI + */ +@Repository +public class BeaconRssiWindowRedisDAO { + + /** + * 滑动窗口 Key 模式 + *

+ * 格式:iot:clean:beacon:rssi:window:{deviceId}:{areaId} + */ + private static final String WINDOW_KEY_PATTERN = "iot:clean:beacon:rssi:window:%s:%s"; + + /** + * 窗口样本的 TTL(秒) + *

+ * 默认保留 1 小时,防止 Redis 内存溢出 + */ + private static final int WINDOW_TTL_SECONDS = 3600; + + @Resource + private StringRedisTemplate stringRedisTemplate; + + /** + * 更新滑动窗口(保留最近 N 个样本) + *

+ * 如果窗口大小超过 maxSize,则移除最旧的样本 + * + * @param deviceId 设备ID + * @param areaId 区域ID + * @param rssi RSSI 值 + * @param maxSize 窗口最大大小 + */ + public void updateWindow(Long deviceId, Long areaId, Integer rssi, Integer maxSize) { + String key = formatKey(deviceId, areaId); + + // 获取当前窗口 + List window = getWindow(deviceId, areaId); + + // 添加新样本 + window.add(rssi); + + // 如果超过最大大小,移除最旧的样本 + if (window.size() > maxSize) { + window = window.subList(window.size() - maxSize, window.size()); + } + + // 转换为逗号分隔的字符串存储 + String windowStr = window.stream() + .map(String::valueOf) + .collect(Collectors.joining(",")); + + // 存储到 Redis + stringRedisTemplate.opsForValue().set(key, windowStr, WINDOW_TTL_SECONDS, TimeUnit.SECONDS); + } + + /** + * 获取窗口样本 + * + * @param deviceId 设备ID + * @param areaId 区域ID + * @return 窗口样本列表(按时间顺序,从旧到新) + */ + public List getWindow(Long deviceId, Long areaId) { + String key = formatKey(deviceId, areaId); + String windowStr = stringRedisTemplate.opsForValue().get(key); + + if (windowStr == null || windowStr.isEmpty()) { + return new ArrayList<>(); + } + + // 解析逗号分隔的字符串 + return List.of(windowStr.split(",")) + .stream() + .map(Integer::parseInt) + .collect(Collectors.toList()); + } + + /** + * 清除窗口 + * + * @param deviceId 设备ID + * @param areaId 区域ID + */ + public void clearWindow(Long deviceId, Long areaId) { + String key = formatKey(deviceId, areaId); + stringRedisTemplate.delete(key); + } + + /** + * 格式化 Redis Key + */ + private static String formatKey(Long deviceId, Long areaId) { + return String.format(WINDOW_KEY_PATTERN, deviceId, areaId); + } +} diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/redis/clean/DeviceCurrentOrderRedisDAO.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/redis/clean/DeviceCurrentOrderRedisDAO.java new file mode 100644 index 0000000..f0f847b --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/redis/clean/DeviceCurrentOrderRedisDAO.java @@ -0,0 +1,178 @@ +package com.viewsh.module.iot.dal.redis.clean; + +import com.viewsh.framework.common.util.json.JsonUtils; +import jakarta.annotation.Resource; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Repository; + +import java.util.concurrent.TimeUnit; + +/** + * 设备当前工单缓存 Redis DAO + *

+ * 用于缓存设备当前执行的工单信息(由 Ops 下发) + * 减少物联网模块查询数据库的频率 + * + * @author AI + */ +@Repository +public class DeviceCurrentOrderRedisDAO { + + /** + * 工单缓存 Key 模式 + *

+ * 格式:ops:clean:device:order:{deviceId} + */ + private static final String ORDER_KEY_PATTERN = "ops:clean:device:order:%s"; + + /** + * 工单缓存的 TTL(秒) + *

+ * 默认保留 1 小时 + */ + private static final int ORDER_TTL_SECONDS = 3600; + + @Resource + private StringRedisTemplate stringRedisTemplate; + + /** + * 缓存设备当前工单 + * + * @param deviceId 设备ID + * @param orderInfo 工单缓存信息 + */ + public void cacheCurrentOrder(Long deviceId, OrderCacheInfo orderInfo) { + String key = formatKey(deviceId); + String json = JsonUtils.toJsonString(orderInfo); + stringRedisTemplate.opsForValue().set(key, json, ORDER_TTL_SECONDS, TimeUnit.SECONDS); + } + + /** + * 获取当前工单 + * + * @param deviceId 设备ID + * @return 工单缓存信息,如果不存在返回 null + */ + public OrderCacheInfo getCurrentOrder(Long deviceId) { + String key = formatKey(deviceId); + String json = stringRedisTemplate.opsForValue().get(key); + + if (json == null) { + return null; + } + + return JsonUtils.parseObject(json, OrderCacheInfo.class); + } + + /** + * 清除当前工单缓存 + * + * @param deviceId 设备ID + */ + public void clearCurrentOrder(Long deviceId) { + String key = formatKey(deviceId); + stringRedisTemplate.delete(key); + } + + /** + * 缓存被暂停的工单ID + *

+ * 用于 P0 插队场景,恢复被暂停的工单 + * + * @param deviceId 设备ID + * @param pausedOrderId 被暂停的工单ID + */ + public void cachePausedOrder(Long deviceId, Long pausedOrderId) { + String key = formatKey(deviceId) + ":paused"; + stringRedisTemplate.opsForValue().set(key, String.valueOf(pausedOrderId), + ORDER_TTL_SECONDS, TimeUnit.SECONDS); + } + + /** + * 获取被暂停的工单ID + * + * @param deviceId 设备ID + * @return 被暂停的工单ID,如果不存在返回 null + */ + public Long getPausedOrderId(Long deviceId) { + String key = formatKey(deviceId) + ":paused"; + String orderIdStr = stringRedisTemplate.opsForValue().get(key); + return orderIdStr != null ? Long.parseLong(orderIdStr) : null; + } + + /** + * 清除被暂停的工单缓存 + * + * @param deviceId 设备ID + */ + public void clearPausedOrder(Long deviceId) { + String key = formatKey(deviceId) + ":paused"; + stringRedisTemplate.delete(key); + } + + /** + * 格式化 Redis Key + */ + private static String formatKey(Long deviceId) { + return String.format(ORDER_KEY_PATTERN, deviceId); + } + + /** + * 工单缓存信息 + */ + public static class OrderCacheInfo { + /** + * 工单ID + */ + private Long orderId; + + /** + * 工单状态 + *

+ * DISPATCHED/ARRIVED/PAUSED/COMPLETED + */ + private String status; + + /** + * 区域ID + */ + private Long areaId; + + /** + * 信标 MAC 地址 + */ + private String beaconMac; + + public Long getOrderId() { + return orderId; + } + + public void setOrderId(Long orderId) { + this.orderId = orderId; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public Long getAreaId() { + return areaId; + } + + public void setAreaId(Long areaId) { + this.areaId = areaId; + } + + public String getBeaconMac() { + return beaconMac; + } + + public void setBeaconMac(String beaconMac) { + this.beaconMac = beaconMac; + } + } +} diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/redis/clean/SignalLossRedisDAO.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/redis/clean/SignalLossRedisDAO.java new file mode 100644 index 0000000..7afe8c3 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/redis/clean/SignalLossRedisDAO.java @@ -0,0 +1,168 @@ +package com.viewsh.module.iot.dal.redis.clean; + +import jakarta.annotation.Resource; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Repository; + +import java.util.concurrent.TimeUnit; + +/** + * 信号丢失状态记录 Redis DAO + *

+ * 用于记录保洁员离岗状态,支持离岗警告防抖、超时判断及无效作业拦截 + * + * @author AI + */ +@Repository +public class SignalLossRedisDAO { + + /** + * 信号丢失 Key 模式 + *

+ * 格式:iot:clean:signal:loss:{deviceId}:{areaId} + * 使用 Hash 存储多个字段 + */ + private static final String LOSS_KEY_PATTERN = "iot:clean:signal:loss:%s:%s"; + + /** + * Hash 字段名 + */ + private static final String FIELD_FIRST_LOSS_TIME = "firstLossTime"; + private static final String FIELD_LAST_LOSS_TIME = "lastLossTime"; + private static final String FIELD_WARNING_SENT = "warningSent"; + private static final String FIELD_INVALID_WORK_NOTIFIED = "invalidWorkNotified"; + + /** + * 丢失记录的 TTL(秒) + *

+ * 默认保留 1 小时 + */ + private static final int LOSS_TTL_SECONDS = 3600; + + @Resource + private StringRedisTemplate stringRedisTemplate; + + /** + * 记录首次丢失 + * + * @param deviceId 设备ID + * @param areaId 区域ID + * @param timestamp 时间戳(毫秒) + */ + public void recordFirstLoss(Long deviceId, Long areaId, Long timestamp) { + String key = formatKey(deviceId, areaId); + + // 使用 HSETNX,仅当字段不存在时才设置(防止覆盖首次丢失时间) + stringRedisTemplate.opsForHash().putIfAbsent(key, FIELD_FIRST_LOSS_TIME, String.valueOf(timestamp)); + + // 更新最后丢失时间 + stringRedisTemplate.opsForHash().put(key, FIELD_LAST_LOSS_TIME, String.valueOf(timestamp)); + + // 设置 TTL + stringRedisTemplate.expire(key, LOSS_TTL_SECONDS, TimeUnit.SECONDS); + } + + /** + * 获取首次丢失时间 + * + * @param deviceId 设备ID + * @param areaId 区域ID + * @return 时间戳(毫秒),如果不存在返回 null + */ + public Long getFirstLossTime(Long deviceId, Long areaId) { + String key = formatKey(deviceId, areaId); + Object value = stringRedisTemplate.opsForHash().get(key, FIELD_FIRST_LOSS_TIME); + return value != null ? Long.parseLong(value.toString()) : null; + } + + /** + * 更新最后丢失时间 + * + * @param deviceId 设备ID + * @param areaId 区域ID + * @param timestamp 时间戳(毫秒) + */ + public void updateLastLossTime(Long deviceId, Long areaId, Long timestamp) { + String key = formatKey(deviceId, areaId); + stringRedisTemplate.opsForHash().put(key, FIELD_LAST_LOSS_TIME, String.valueOf(timestamp)); + } + + /** + * 获取最后丢失时间 + * + * @param deviceId 设备ID + * @param areaId 区域ID + * @return 时间戳(毫秒),如果不存在返回 null + */ + public Long getLastLossTime(Long deviceId, Long areaId) { + String key = formatKey(deviceId, areaId); + Object value = stringRedisTemplate.opsForHash().get(key, FIELD_LAST_LOSS_TIME); + return value != null ? Long.parseLong(value.toString()) : null; + } + + /** + * 检查是否已发送警告 + * + * @param deviceId 设备ID + * @param areaId 区域ID + * @return true - 已发送,false - 未发送 + */ + public Boolean isWarningSent(Long deviceId, Long areaId) { + String key = formatKey(deviceId, areaId); + Object value = stringRedisTemplate.opsForHash().get(key, FIELD_WARNING_SENT); + return value != null && Boolean.parseBoolean(value.toString()); + } + + /** + * 标记警告已发送 + * + * @param deviceId 设备ID + * @param areaId 区域ID + */ + public void markWarningSent(Long deviceId, Long areaId) { + String key = formatKey(deviceId, areaId); + stringRedisTemplate.opsForHash().put(key, FIELD_WARNING_SENT, "true"); + } + + /** + * 检查是否已通知无效作业 + * + * @param deviceId 设备ID + * @param areaId 区域ID + * @return true - 已通知,false - 未通知 + */ + public Boolean isInvalidWorkNotified(Long deviceId, Long areaId) { + String key = formatKey(deviceId, areaId); + Object value = stringRedisTemplate.opsForHash().get(key, FIELD_INVALID_WORK_NOTIFIED); + return value != null && Boolean.parseBoolean(value.toString()); + } + + /** + * 标记无效作业已通知 + * + * @param deviceId 设备ID + * @param areaId 区域ID + */ + public void markInvalidWorkNotified(Long deviceId, Long areaId) { + String key = formatKey(deviceId, areaId); + stringRedisTemplate.opsForHash().put(key, FIELD_INVALID_WORK_NOTIFIED, "true"); + } + + /** + * 清除丢失记录 + * + * @param deviceId 设备ID + * @param areaId 区域ID + */ + public void clearLossRecord(Long deviceId, Long areaId) { + String key = formatKey(deviceId, areaId); + stringRedisTemplate.delete(key); + } + + /** + * 格式化 Redis Key + */ + private static String formatKey(Long deviceId, Long areaId) { + return String.format(LOSS_KEY_PATTERN, deviceId, areaId); + } +} diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/redis/clean/TrafficCounterBaseRedisDAO.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/redis/clean/TrafficCounterBaseRedisDAO.java new file mode 100644 index 0000000..2cc9a78 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/redis/clean/TrafficCounterBaseRedisDAO.java @@ -0,0 +1,93 @@ +package com.viewsh.module.iot.dal.redis.clean; + +import jakarta.annotation.Resource; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Repository; + +import java.util.concurrent.TimeUnit; + +/** + * 客流计数器基准值 Redis DAO + *

+ * 用于维护客流计数器的基准值,支持逻辑清零和每日自动校准 + * 实际客流 = 当前计数值 - 基准值 + * + * @author AI + */ +@Repository +public class TrafficCounterBaseRedisDAO { + + /** + * 基准值 Key 模式 + *

+ * 格式:iot:clean:traffic:base:{deviceId} + */ + private static final String BASE_KEY_PATTERN = "iot:clean:traffic:base:%s"; + + /** + * 基准值的 TTL(秒) + *

+ * 默认保留 7 天 + */ + private static final int BASE_TTL_SECONDS = 604800; + + @Resource + private StringRedisTemplate stringRedisTemplate; + + /** + * 设置基准值 + * + * @param deviceId 设备ID + * @param baseValue 基准值 + */ + public void setBaseValue(Long deviceId, Long baseValue) { + String key = formatKey(deviceId); + stringRedisTemplate.opsForValue().set(key, String.valueOf(baseValue), + BASE_TTL_SECONDS, TimeUnit.SECONDS); + } + + /** + * 获取基准值 + * + * @param deviceId 设备ID + * @return 基准值,如果不存在返回 0 + */ + public Long getBaseValue(Long deviceId) { + String key = formatKey(deviceId); + String baseValueStr = stringRedisTemplate.opsForValue().get(key); + + if (baseValueStr == null) { + return 0L; + } + + return Long.parseLong(baseValueStr); + } + + /** + * 重置基准值 + *

+ * 将基准值设置为 0 + * + * @param deviceId 设备ID + */ + public void resetBaseValue(Long deviceId) { + setBaseValue(deviceId, 0L); + } + + /** + * 删除基准值 + * + * @param deviceId 设备ID + */ + public void deleteBaseValue(Long deviceId) { + String key = formatKey(deviceId); + stringRedisTemplate.delete(key); + } + + /** + * 格式化 Redis Key + */ + private static String formatKey(Long deviceId) { + return String.format(BASE_KEY_PATTERN, deviceId); + } +} diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/integration/clean/CleanOrderIntegrationConfigService.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/integration/clean/CleanOrderIntegrationConfigService.java new file mode 100644 index 0000000..4f58291 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/integration/clean/CleanOrderIntegrationConfigService.java @@ -0,0 +1,175 @@ +package com.viewsh.module.iot.service.integration.clean; + +import com.viewsh.module.iot.dal.dataobject.integration.clean.CleanOrderIntegrationConfig; + +/** + * 保洁工单集成配置 Service + *

+ * 提供运营区域设备关联配置的查询和管理能力 + * 支持本地 Redis 缓存,减少数据库查询压力 + * + * @author AI + */ +public interface CleanOrderIntegrationConfigService { + + /** + * 根据设备ID查询配置(带缓存) + *

+ * 优先从 Redis 缓存读取,缓存未命中时从数据库查询并写入缓存 + * 缓存 TTL: 5 分钟 + * + * @param deviceId 设备ID + * @return 集成配置,如果不存在或未启用返回 null + */ + CleanOrderIntegrationConfig getConfigByDeviceId(Long deviceId); + + /** + * 根据区域ID查询所有启用的配置(带缓存) + *

+ * 优先从 Redis 缓存读取 + * + * @param areaId 区域ID + * @return 集成配置列表 + */ + java.util.List getConfigsByAreaId(Long areaId); + + /** + * 根据区域ID和关联类型查询配置 + *

+ * 例如:查询某个区域的所有客流计数器配置 + * + * @param areaId 区域ID + * @param relationType 关联类型(TRAFFIC_COUNTER/BEACON/BADGE) + * @return 集成配置列表 + */ + java.util.List getConfigsByAreaIdAndRelationType(Long areaId, String relationType); + + /** + * 清除设备配置缓存 + *

+ * 配置更新后调用此方法清除缓存,确保下次查询能获取最新配置 + * + * @param deviceId 设备ID + */ + void evictCache(Long deviceId); + + /** + * 清除区域配置缓存 + * + * @param areaId 区域ID + */ + void evictAreaCache(Long areaId); + + /** + * 区域设备配置包装类 + *

+ * 包含配置数据和关联的基础信息(设备ID、区域ID、关联类型等) + */ + class AreaDeviceConfigWrapper { + /** + * 设备ID + */ + private Long deviceId; + + /** + * 设备Key + */ + private String deviceKey; + + /** + * 产品ID + */ + private Long productId; + + /** + * 产品Key + */ + private String productKey; + + /** + * 区域ID + */ + private Long areaId; + + /** + * 关联类型 + */ + private String relationType; + + /** + * 集成配置 + */ + private CleanOrderIntegrationConfig config; + + public AreaDeviceConfigWrapper() { + } + + public AreaDeviceConfigWrapper(Long deviceId, String deviceKey, Long productId, + String productKey, Long areaId, String relationType, + CleanOrderIntegrationConfig config) { + this.deviceId = deviceId; + this.deviceKey = deviceKey; + this.productId = productId; + this.productKey = productKey; + this.areaId = areaId; + this.relationType = relationType; + this.config = config; + } + + public Long getDeviceId() { + return deviceId; + } + + public void setDeviceId(Long deviceId) { + this.deviceId = deviceId; + } + + public String getDeviceKey() { + return deviceKey; + } + + public void setDeviceKey(String deviceKey) { + this.deviceKey = deviceKey; + } + + public Long getProductId() { + return productId; + } + + public void setProductId(Long productId) { + this.productId = productId; + } + + public String getProductKey() { + return productKey; + } + + public void setProductKey(String productKey) { + this.productKey = productKey; + } + + public Long getAreaId() { + return areaId; + } + + public void setAreaId(Long areaId) { + this.areaId = areaId; + } + + public String getRelationType() { + return relationType; + } + + public void setRelationType(String relationType) { + this.relationType = relationType; + } + + public CleanOrderIntegrationConfig getConfig() { + return config; + } + + public void setConfig(CleanOrderIntegrationConfig config) { + this.config = config; + } + } +} diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/integration/clean/CleanOrderIntegrationConfigServiceImpl.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/integration/clean/CleanOrderIntegrationConfigServiceImpl.java new file mode 100644 index 0000000..2d96da9 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/integration/clean/CleanOrderIntegrationConfigServiceImpl.java @@ -0,0 +1,152 @@ +package com.viewsh.module.iot.service.integration.clean; + +import com.viewsh.framework.common.util.json.JsonUtils; +import com.viewsh.module.iot.dal.dataobject.integration.clean.CleanOrderIntegrationConfig; +import com.viewsh.module.iot.dal.dataobject.integration.clean.OpsAreaDeviceRelationDO; +import com.viewsh.module.iot.dal.mysql.integration.clean.OpsAreaDeviceRelationMapper; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +/** + * 保洁工单集成配置 Service 实现类 + * + * @author AI + */ +@Service +@Slf4j +public class CleanOrderIntegrationConfigServiceImpl implements CleanOrderIntegrationConfigService { + + /** + * 配置缓存 Key 模式 + */ + private static final String CONFIG_DEVICE_KEY_PATTERN = "iot:clean:config:device:%s"; + private static final String CONFIG_AREA_KEY_PATTERN = "iot:clean:config:area:%s"; + + /** + * 配置缓存 TTL(秒) + *

+ * 5 分钟自动过期 + */ + private static final int CONFIG_CACHE_TTL_SECONDS = 300; + + @Resource + private OpsAreaDeviceRelationMapper relationMapper; + + @Resource + private StringRedisTemplate stringRedisTemplate; + + @Override + public CleanOrderIntegrationConfig getConfigByDeviceId(Long deviceId) { + log.debug("[CleanOrderConfig] 查询设备配置:deviceId={}", deviceId); + + // 1. 尝试从 Redis 缓存读取 + String cacheKey = formatDeviceKey(deviceId); + String cachedConfig = stringRedisTemplate.opsForValue().get(cacheKey); + + if (cachedConfig != null) { + log.debug("[CleanOrderConfig] 命中缓存:deviceId={}", deviceId); + return JsonUtils.parseObject(cachedConfig, CleanOrderIntegrationConfig.class); + } + + // 2. 从数据库查询 + OpsAreaDeviceRelationDO relation = relationMapper.selectByDeviceId(deviceId); + + if (relation == null) { + log.debug("[CleanOrderConfig] 设备无关联配置:deviceId={}", deviceId); + return null; + } + + if (!relation.getEnabled()) { + log.debug("[CleanOrderConfig] 设备配置已禁用:deviceId={}", deviceId); + return null; + } + + CleanOrderIntegrationConfig config = relation.getConfigData(); + + // 3. 写入缓存 + if (config != null) { + stringRedisTemplate.opsForValue().set( + cacheKey, + JsonUtils.toJsonString(config), + CONFIG_CACHE_TTL_SECONDS, + TimeUnit.SECONDS + ); + } + + log.debug("[CleanOrderConfig] 查询到配置:deviceId={}, areaId={}", deviceId, relation.getAreaId()); + + return config; + } + + @Override + public List getConfigsByAreaId(Long areaId) { + log.debug("[CleanOrderConfig] 查询区域配置:areaId={}", areaId); + + // 区域配置暂不缓存,直接从数据库查询 + List relations = relationMapper.selectListByAreaId(areaId); + + return relations.stream() + .map(this::wrapConfig) + .collect(Collectors.toList()); + } + + @Override + public List getConfigsByAreaIdAndRelationType(Long areaId, String relationType) { + log.debug("[CleanOrderConfig] 查询区域配置:areaId={}, relationType={}", areaId, relationType); + + List relations = relationMapper.selectListByAreaIdAndRelationType(areaId, relationType); + + return relations.stream() + .map(this::wrapConfig) + .collect(Collectors.toList()); + } + + @Override + public void evictCache(Long deviceId) { + String cacheKey = formatDeviceKey(deviceId); + stringRedisTemplate.delete(cacheKey); + log.info("[CleanOrderConfig] 清除设备配置缓存:deviceId={}", deviceId); + } + + @Override + public void evictAreaCache(Long areaId) { + String cacheKey = formatAreaKey(areaId); + stringRedisTemplate.delete(cacheKey); + log.info("[CleanOrderConfig] 清除区域配置缓存:areaId={}", areaId); + } + + /** + * 包装配置数据 + */ + private AreaDeviceConfigWrapper wrapConfig(OpsAreaDeviceRelationDO relation) { + return new AreaDeviceConfigWrapper( + relation.getDeviceId(), + relation.getDeviceKey(), + relation.getProductId(), + relation.getProductKey(), + relation.getAreaId(), + relation.getRelationType(), + relation.getConfigData() + ); + } + + /** + * 格式化设备配置缓存 Key + */ + private static String formatDeviceKey(Long deviceId) { + return String.format(CONFIG_DEVICE_KEY_PATTERN, deviceId); + } + + /** + * 格式化区域配置缓存 Key + */ + private static String formatAreaKey(Long areaId) { + return String.format(CONFIG_AREA_KEY_PATTERN, areaId); + } +}