From 11dcb57ff31a05aad95c74edd32d6d04c4585a01 Mon Sep 17 00:00:00 2001 From: lzh Date: Tue, 31 Mar 2026 22:53:06 +0800 Subject: [PATCH] =?UTF-8?q?feat(trajectory):=20=E6=96=B0=E5=A2=9E=E8=BD=A8?= =?UTF-8?q?=E8=BF=B9=E6=A3=80=E6=B5=8B=E4=B8=8E=20Beacon=20=E6=B3=A8?= =?UTF-8?q?=E5=86=8C=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../iot/api/trajectory/DeviceLocationDTO.java | 36 ++ .../api/trajectory/TrajectoryStateApi.java | 31 ++ .../constants/TrajectoryTopics.java | 22 + .../trajectory/TrajectoryEnterEvent.java | 72 +++ .../trajectory/TrajectoryLeaveEvent.java | 81 ++++ .../rpc/TrajectoryStateController.java | 46 ++ .../clean/TrajectoryTrackingConfig.java | 26 ++ .../clean/TrajectoryRssiWindowRedisDAO.java | 71 +++ .../redis/clean/TrajectoryStateRedisDAO.java | 109 +++++ .../clean/BeaconRegistryService.java | 51 +++ .../clean/BeaconRegistryServiceImpl.java | 165 +++++++ .../rule/clean/CleanRuleProcessorManager.java | 13 +- .../TrajectoryDetectionProcessor.java | 432 ++++++++++++++++++ .../module/ops/api/area/AreaDeviceApi.java | 22 +- .../ops/service/area/AreaDeviceService.java | 17 + .../service/area/AreaDeviceServiceImpl.java | 10 + .../controller/area/AreaDeviceController.java | 37 +- 17 files changed, 1216 insertions(+), 25 deletions(-) create mode 100644 viewsh-module-iot/viewsh-module-iot-api/src/main/java/com/viewsh/module/iot/api/trajectory/DeviceLocationDTO.java create mode 100644 viewsh-module-iot/viewsh-module-iot-api/src/main/java/com/viewsh/module/iot/api/trajectory/TrajectoryStateApi.java create mode 100644 viewsh-module-iot/viewsh-module-iot-core/src/main/java/com/viewsh/module/iot/core/integration/constants/TrajectoryTopics.java create mode 100644 viewsh-module-iot/viewsh-module-iot-core/src/main/java/com/viewsh/module/iot/core/integration/event/trajectory/TrajectoryEnterEvent.java create mode 100644 viewsh-module-iot/viewsh-module-iot-core/src/main/java/com/viewsh/module/iot/core/integration/event/trajectory/TrajectoryLeaveEvent.java create mode 100644 viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/rpc/TrajectoryStateController.java create mode 100644 viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/dataobject/integration/clean/TrajectoryTrackingConfig.java create mode 100644 viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/redis/clean/TrajectoryRssiWindowRedisDAO.java create mode 100644 viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/redis/clean/TrajectoryStateRedisDAO.java create mode 100644 viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/integration/clean/BeaconRegistryService.java create mode 100644 viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/integration/clean/BeaconRegistryServiceImpl.java create mode 100644 viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/rule/clean/processor/TrajectoryDetectionProcessor.java diff --git a/viewsh-module-iot/viewsh-module-iot-api/src/main/java/com/viewsh/module/iot/api/trajectory/DeviceLocationDTO.java b/viewsh-module-iot/viewsh-module-iot-api/src/main/java/com/viewsh/module/iot/api/trajectory/DeviceLocationDTO.java new file mode 100644 index 0000000..14ff1eb --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-api/src/main/java/com/viewsh/module/iot/api/trajectory/DeviceLocationDTO.java @@ -0,0 +1,36 @@ +package com.viewsh.module.iot.api.trajectory; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 设备实时位置 DTO + * + * @author lzh + */ +@Schema(description = "RPC - 设备实时位置") +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class DeviceLocationDTO { + + @Schema(description = "设备ID", example = "31") + private Long deviceId; + + @Schema(description = "当前所在区域ID", example = "1301") + private Long areaId; + + @Schema(description = "进入时间(毫秒时间戳)", example = "1711872600000") + private Long enterTime; + + @Schema(description = "匹配的Beacon MAC", example = "F0:C8:60:1D:10:BB") + private String beaconMac; + + @Schema(description = "是否在某区域内") + private Boolean inArea; + +} diff --git a/viewsh-module-iot/viewsh-module-iot-api/src/main/java/com/viewsh/module/iot/api/trajectory/TrajectoryStateApi.java b/viewsh-module-iot/viewsh-module-iot-api/src/main/java/com/viewsh/module/iot/api/trajectory/TrajectoryStateApi.java new file mode 100644 index 0000000..40933e8 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-api/src/main/java/com/viewsh/module/iot/api/trajectory/TrajectoryStateApi.java @@ -0,0 +1,31 @@ +package com.viewsh.module.iot.api.trajectory; + +import com.viewsh.framework.common.pojo.CommonResult; +import com.viewsh.module.iot.enums.ApiConstants; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; + +/** + * 轨迹实时状态 API + *

+ * 提供 RPC 接口供 Ops 模块查询设备当前位置 + * + * @author lzh + */ +@FeignClient(name = ApiConstants.NAME) +@Tag(name = "RPC 服务 - 轨迹实时状态") +public interface TrajectoryStateApi { + + String PREFIX = ApiConstants.PREFIX + "/trajectory"; + + @GetMapping(PREFIX + "/current-location") + @Operation(summary = "查询设备当前位置") + CommonResult getCurrentLocation( + @Parameter(description = "设备ID", required = true, example = "31") + @RequestParam("deviceId") Long deviceId); + +} diff --git a/viewsh-module-iot/viewsh-module-iot-core/src/main/java/com/viewsh/module/iot/core/integration/constants/TrajectoryTopics.java b/viewsh-module-iot/viewsh-module-iot-core/src/main/java/com/viewsh/module/iot/core/integration/constants/TrajectoryTopics.java new file mode 100644 index 0000000..2b849bb --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-core/src/main/java/com/viewsh/module/iot/core/integration/constants/TrajectoryTopics.java @@ -0,0 +1,22 @@ +package com.viewsh.module.iot.core.integration.constants; + +/** + * 轨迹事件 Topic 常量 + *

+ * 定义 IoT → Ops 的轨迹事件 Topic + * + * @author lzh + */ +public interface TrajectoryTopics { + + /** + * 进入区域事件 + */ + String TRAJECTORY_ENTER = "trajectory-enter"; + + /** + * 离开区域事件 + */ + String TRAJECTORY_LEAVE = "trajectory-leave"; + +} 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 new file mode 100644 index 0000000..228baea --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-core/src/main/java/com/viewsh/module/iot/core/integration/event/trajectory/TrajectoryEnterEvent.java @@ -0,0 +1,72 @@ +package com.viewsh.module.iot.core.integration.event.trajectory; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.UUID; + +/** + * 轨迹进入区域事件 + *

+ * 当工牌蓝牙信号满足强进条件确认进入某区域时发布 + * Topic: trajectory-enter + * + * @author lzh + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TrajectoryEnterEvent { + + /** + * 事件ID(唯一标识,用于幂等性处理) + */ + @Builder.Default + private String eventId = UUID.randomUUID().toString(); + + /** + * 设备ID(工牌) + */ + private Long deviceId; + + /** + * 设备名称 + */ + private String deviceName; + + /** + * 设备备注名称 + */ + private String nickname; + + /** + * 区域ID + */ + private Long areaId; + + /** + * 匹配的 Beacon MAC 地址 + */ + private String beaconMac; + + /** + * 进入时的 RSSI 值 + */ + private Integer enterRssi; + + /** + * 事件时间(ISO-8601 字符串,跨模块序列化安全) + */ + @Builder.Default + private String eventTime = LocalDateTime.now().toString(); + + /** + * 租户ID + */ + private Long tenantId; + +} 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 new file mode 100644 index 0000000..e501561 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-core/src/main/java/com/viewsh/module/iot/core/integration/event/trajectory/TrajectoryLeaveEvent.java @@ -0,0 +1,81 @@ +package com.viewsh.module.iot.core.integration.event.trajectory; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.UUID; + +/** + * 轨迹离开区域事件 + *

+ * 当工牌蓝牙信号满足弱出条件确认离开某区域时发布 + * Topic: trajectory-leave + * + * @author lzh + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TrajectoryLeaveEvent { + + /** + * 事件ID(唯一标识,用于幂等性处理) + */ + @Builder.Default + private String eventId = UUID.randomUUID().toString(); + + /** + * 设备ID(工牌) + */ + private Long deviceId; + + /** + * 设备名称 + */ + private String deviceName; + + /** + * 设备备注名称 + */ + private String nickname; + + /** + * 区域ID + */ + private Long areaId; + + /** + * 匹配的 Beacon MAC 地址 + */ + private String beaconMac; + + /** + * 离开原因 + *

+ * SIGNAL_LOSS - 信号丢失(弱出条件满足) + * AREA_SWITCH - 切换到其他区域 + * DEVICE_OFFLINE - 设备离线 + */ + private String leaveReason; + + /** + * 进入时间戳(毫秒),用于 Ops 端匹配记录 + */ + private Long enterTimestamp; + + /** + * 事件时间(ISO-8601 字符串,跨模块序列化安全) + */ + @Builder.Default + private String eventTime = LocalDateTime.now().toString(); + + /** + * 租户ID + */ + private Long tenantId; + +} diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/rpc/TrajectoryStateController.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/rpc/TrajectoryStateController.java new file mode 100644 index 0000000..9d4744f --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/controller/rpc/TrajectoryStateController.java @@ -0,0 +1,46 @@ +package com.viewsh.module.iot.controller.rpc; + +import com.viewsh.framework.common.pojo.CommonResult; +import com.viewsh.module.iot.api.trajectory.DeviceLocationDTO; +import com.viewsh.module.iot.api.trajectory.TrajectoryStateApi; +import com.viewsh.module.iot.dal.redis.clean.TrajectoryStateRedisDAO; +import jakarta.annotation.Resource; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.RestController; + +import static com.viewsh.framework.common.pojo.CommonResult.success; + +/** + * 轨迹实时状态 RPC Controller + *

+ * 实现 {@link TrajectoryStateApi},从 Redis 读取设备当前位置 + * + * @author lzh + */ +@RestController +@Validated +public class TrajectoryStateController implements TrajectoryStateApi { + + @Resource + private TrajectoryStateRedisDAO trajectoryStateRedisDAO; + + @Override + public CommonResult getCurrentLocation(Long deviceId) { + TrajectoryStateRedisDAO.CurrentAreaInfo currentArea = trajectoryStateRedisDAO.getCurrentArea(deviceId); + + if (currentArea == null) { + return success(DeviceLocationDTO.builder() + .deviceId(deviceId) + .inArea(false) + .build()); + } + + return success(DeviceLocationDTO.builder() + .deviceId(deviceId) + .areaId(currentArea.getAreaId()) + .enterTime(currentArea.getEnterTime()) + .beaconMac(currentArea.getBeaconMac()) + .inArea(true) + .build()); + } +} diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/dataobject/integration/clean/TrajectoryTrackingConfig.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/dataobject/integration/clean/TrajectoryTrackingConfig.java new file mode 100644 index 0000000..8df8c6a --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/dataobject/integration/clean/TrajectoryTrackingConfig.java @@ -0,0 +1,26 @@ +package com.viewsh.module.iot.dal.dataobject.integration.clean; + +import lombok.Data; + +/** + * 轨迹记录功能配置 + *

+ * 存储在 iot_device.config JSON 字段中,与 ButtonEventConfig 同级 + *

+ * {
+ *   "buttonEvent": { ... },
+ *   "trajectoryTracking": { "enabled": true }
+ * }
+ * 
+ * + * @author lzh + */ +@Data +public class TrajectoryTrackingConfig { + + /** + * 是否启用轨迹记录 + */ + private Boolean enabled; + +} diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/redis/clean/TrajectoryRssiWindowRedisDAO.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/redis/clean/TrajectoryRssiWindowRedisDAO.java new file mode 100644 index 0000000..518a67c --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/redis/clean/TrajectoryRssiWindowRedisDAO.java @@ -0,0 +1,71 @@ +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 + *

+ * 与 BeaconRssiWindowRedisDAO 功能相同,使用独立的 Key 前缀, + * 避免与保洁到岗检测的窗口数据互相干扰 + * + * @author lzh + */ +@Repository +public class TrajectoryRssiWindowRedisDAO { + + private static final String WINDOW_KEY_PATTERN = "iot:trajectory:rssi:window:%s:%s"; + private static final int WINDOW_TTL_SECONDS = 3600; + + @Resource + private StringRedisTemplate stringRedisTemplate; + + /** + * 更新滑动窗口(保留最近 N 个样本) + */ + 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(",")); + stringRedisTemplate.opsForValue().set(key, windowStr, WINDOW_TTL_SECONDS, TimeUnit.SECONDS); + } + + /** + * 获取窗口样本 + */ + 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()); + } + + /** + * 清除窗口 + */ + public void clearWindow(Long deviceId, Long areaId) { + String key = formatKey(deviceId, areaId); + stringRedisTemplate.delete(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/TrajectoryStateRedisDAO.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/redis/clean/TrajectoryStateRedisDAO.java new file mode 100644 index 0000000..c38ec37 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/redis/clean/TrajectoryStateRedisDAO.java @@ -0,0 +1,109 @@ +package com.viewsh.module.iot.dal.redis.clean; + +import jakarta.annotation.Resource; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Repository; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +/** + * 轨迹实时状态 Redis DAO + *

+ * 维护每个设备当前所在区域的实时状态 + *

+ * Key: iot:trajectory:current:{deviceId} + * Type: Hash + * Fields: areaId, enterTime, beaconMac + * TTL: 24小时 + * + * @author lzh + */ +@Slf4j +@Repository +public class TrajectoryStateRedisDAO { + + private static final String CURRENT_KEY_PATTERN = "iot:trajectory:current:%s"; + private static final int CURRENT_TTL_SECONDS = 86400; // 24小时 + + private static final String FIELD_AREA_ID = "areaId"; + private static final String FIELD_ENTER_TIME = "enterTime"; + private static final String FIELD_BEACON_MAC = "beaconMac"; + + @Resource + private StringRedisTemplate stringRedisTemplate; + + /** + * 设置当前所在区域 + */ + public void setCurrentArea(Long deviceId, Long areaId, long enterTime, String beaconMac) { + String key = formatKey(deviceId); + // 先删后写,清除旧字段(如上次的 beaconMac),保证一致性 + stringRedisTemplate.delete(key); + Map fields = new HashMap<>(); + fields.put(FIELD_AREA_ID, String.valueOf(areaId)); + fields.put(FIELD_ENTER_TIME, String.valueOf(enterTime)); + if (beaconMac != null) { + fields.put(FIELD_BEACON_MAC, beaconMac); + } + stringRedisTemplate.opsForHash().putAll(key, fields); + stringRedisTemplate.expire(key, CURRENT_TTL_SECONDS, TimeUnit.SECONDS); + } + + /** + * 获取当前所在区域信息 + * + * @return 当前区域信息,如果不在任何区域则返回 null + */ + public CurrentAreaInfo getCurrentArea(Long deviceId) { + String key = formatKey(deviceId); + Map entries = stringRedisTemplate.opsForHash().entries(key); + if (entries == null || entries.isEmpty()) { + return null; + } + + Object areaIdObj = entries.get(FIELD_AREA_ID); + Object enterTimeObj = entries.get(FIELD_ENTER_TIME); + if (areaIdObj == null || enterTimeObj == null) { + return null; + } + + CurrentAreaInfo info = new CurrentAreaInfo(); + info.setAreaId(Long.parseLong(areaIdObj.toString())); + info.setEnterTime(Long.parseLong(enterTimeObj.toString())); + Object beaconMacObj = entries.get(FIELD_BEACON_MAC); + if (beaconMacObj != null) { + info.setBeaconMac(beaconMacObj.toString()); + } + return info; + } + + /** + * 清除当前区域状态 + */ + public void clearCurrentArea(Long deviceId) { + String key = formatKey(deviceId); + stringRedisTemplate.delete(key); + } + + private static String formatKey(Long deviceId) { + return String.format(CURRENT_KEY_PATTERN, deviceId); + } + + /** + * 当前区域信息 + */ + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class CurrentAreaInfo { + private Long areaId; + private Long enterTime; + private String beaconMac; + } +} diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/integration/clean/BeaconRegistryService.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/integration/clean/BeaconRegistryService.java new file mode 100644 index 0000000..71ac93c --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/integration/clean/BeaconRegistryService.java @@ -0,0 +1,51 @@ +package com.viewsh.module.iot.service.integration.clean; + +import com.viewsh.module.iot.dal.dataobject.integration.clean.BeaconPresenceConfig; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Map; + +/** + * Beacon 全量注册表服务 + *

+ * 为轨迹检测功能提供 beaconMac → {areaId, config} 的全量映射 + *

+ * 数据来源:Ops 模块的 ops_area_device_relation 表(relationType=BEACON) + * 缓存:Redis Hash,1小时 TTL,启动预热 + 定时刷新 + * + * @author lzh + */ +public interface BeaconRegistryService { + + /** + * 获取所有已注册的 Beacon 配置 + * + * @return beaconMac(大写) → BeaconAreaInfo 的映射 + */ + Map getAllBeaconConfigs(); + + /** + * 刷新 Beacon 注册表缓存 + */ + void refreshRegistry(); + + /** + * Beacon 与区域的关联信息 + */ + @Data + @NoArgsConstructor + @AllArgsConstructor + class BeaconAreaInfo { + /** + * 区域ID + */ + private Long areaId; + /** + * Beacon 检测配置 + */ + private BeaconPresenceConfig config; + } + +} diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/integration/clean/BeaconRegistryServiceImpl.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/integration/clean/BeaconRegistryServiceImpl.java new file mode 100644 index 0000000..2e1d4fe --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/integration/clean/BeaconRegistryServiceImpl.java @@ -0,0 +1,165 @@ +package com.viewsh.module.iot.service.integration.clean; + +import com.viewsh.framework.common.pojo.CommonResult; +import com.viewsh.framework.common.util.json.JsonUtils; +import com.viewsh.module.iot.dal.dataobject.integration.clean.BeaconPresenceConfig; +import com.viewsh.module.iot.dal.dataobject.integration.clean.CleanOrderIntegrationConfig; +import com.viewsh.module.ops.api.area.AreaDeviceApi; +import com.viewsh.module.ops.api.area.AreaDeviceDTO; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +/** + * Beacon 全量注册表服务实现 + *

+ * 缓存策略: + * - Redis Hash: iot:trajectory:beacon:registry,field=beaconMac,value=JSON{areaId,config} + * - TTL: 1小时 + * - 启动时预热,每30分钟定时刷新 + * - 如果 Redis 缓存失效,降级为实时调用 Feign API + * + * @author lzh + */ +@Slf4j +@Service +public class BeaconRegistryServiceImpl implements BeaconRegistryService { + + private static final String REGISTRY_KEY = "iot:trajectory:beacon:registry"; + private static final int REGISTRY_TTL_SECONDS = 3600; // 1小时 + + @Resource + private AreaDeviceApi areaDeviceApi; + + @Resource + private StringRedisTemplate stringRedisTemplate; + + @Resource + private CleanOrderIntegrationConfigService configService; + + /** + * 启动时预热缓存 + */ + @PostConstruct + public void init() { + try { + refreshRegistry(); + } catch (Exception e) { + log.warn("[BeaconRegistry] 启动预热失败,将在下次查询时加载", e); + } + } + + /** + * 每30分钟刷新一次注册表 + */ + @Scheduled(fixedDelay = 30 * 60 * 1000, initialDelay = 30 * 60 * 1000) + public void scheduledRefresh() { + try { + refreshRegistry(); + } catch (Exception e) { + log.error("[BeaconRegistry] 定时刷新失败", e); + } + } + + @Override + public Map getAllBeaconConfigs() { + // 1. 尝试从 Redis 读取 + try { + Map entries = stringRedisTemplate.opsForHash().entries(REGISTRY_KEY); + if (entries != null && !entries.isEmpty()) { + Map result = new HashMap<>(entries.size()); + for (Map.Entry entry : entries.entrySet()) { + String mac = entry.getKey().toString(); + BeaconAreaInfo info = JsonUtils.parseObject(entry.getValue().toString(), BeaconAreaInfo.class); + if (info != null) { + result.put(mac, info); + } + } + log.debug("[BeaconRegistry] 从缓存加载 {} 个 Beacon 配置", result.size()); + return result; + } + } catch (Exception e) { + log.warn("[BeaconRegistry] 读取缓存失败,降级为实时查询", e); + } + + // 2. 缓存未命中,实时查询并写入缓存 + return loadAndCacheRegistry(); + } + + @Override + public void refreshRegistry() { + loadAndCacheRegistry(); + } + + /** + * 从 Ops 加载全量 Beacon 注册表并写入 Redis 缓存 + */ + private Map loadAndCacheRegistry() { + Map registry = new HashMap<>(); + + try { + CommonResult> result = areaDeviceApi.getAllEnabledBeacons(); + if (result == null || !result.isSuccess() || result.getData() == null) { + log.warn("[BeaconRegistry] 调用 Ops 获取 Beacon 列表失败"); + return registry; + } + + Map redisEntries = new HashMap<>(); + + for (AreaDeviceDTO dto : result.getData()) { + if (dto.getConfigData() == null || dto.getAreaId() == null) { + continue; + } + + // 反序列化配置 + CleanOrderIntegrationConfig integrationConfig = configService.convertConfig(dto.getConfigData()); + if (integrationConfig == null || integrationConfig.getBeaconPresence() == null) { + continue; + } + + BeaconPresenceConfig beaconConfig = integrationConfig.getBeaconPresence(); + if (beaconConfig.getEnabled() == null || !beaconConfig.getEnabled()) { + continue; + } + + String mac = beaconConfig.getBeaconMac(); + if (mac == null || mac.isEmpty()) { + continue; + } + + // 统一转大写 + mac = mac.toUpperCase(); + + BeaconAreaInfo info = new BeaconAreaInfo(dto.getAreaId(), beaconConfig); + registry.put(mac, info); + redisEntries.put(mac, JsonUtils.toJsonString(info)); + } + + // 写入 Redis(使用临时 Key + rename 保证原子性,避免竞态空窗期) + if (!redisEntries.isEmpty()) { + String tempKey = REGISTRY_KEY + ":tmp:" + System.currentTimeMillis(); + stringRedisTemplate.opsForHash().putAll(tempKey, redisEntries); + stringRedisTemplate.expire(tempKey, REGISTRY_TTL_SECONDS, TimeUnit.SECONDS); + stringRedisTemplate.rename(tempKey, REGISTRY_KEY); + } else { + // 所有 Beacon 都被禁用时,清除旧缓存 + stringRedisTemplate.delete(REGISTRY_KEY); + } + + log.info("[BeaconRegistry] 加载并缓存 {} 个 Beacon 配置", registry.size()); + + } catch (Exception e) { + log.error("[BeaconRegistry] 加载 Beacon 注册表失败", e); + } + + return registry; + } +} diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/rule/clean/CleanRuleProcessorManager.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/rule/clean/CleanRuleProcessorManager.java index b46274f..acf13bf 100644 --- a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/rule/clean/CleanRuleProcessorManager.java +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/rule/clean/CleanRuleProcessorManager.java @@ -5,6 +5,7 @@ import com.viewsh.module.iot.core.mq.message.IotDeviceMessage; import com.viewsh.module.iot.service.rule.clean.processor.BeaconDetectionRuleProcessor; import com.viewsh.module.iot.service.rule.clean.processor.ButtonEventRuleProcessor; import com.viewsh.module.iot.service.rule.clean.processor.TrafficThresholdRuleProcessor; +import com.viewsh.module.iot.service.rule.clean.processor.TrajectoryDetectionProcessor; import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; @@ -33,6 +34,9 @@ public class CleanRuleProcessorManager { @Resource private ButtonEventRuleProcessor buttonEventRuleProcessor; + @Resource + private TrajectoryDetectionProcessor trajectoryDetectionProcessor; + /** * 处理设备消息 *

@@ -76,6 +80,8 @@ public class CleanRuleProcessorManager { // 避免退出检测窗口因无数据而停滞 if (!data.containsKey("bluetoothDevices")) { beaconDetectionRuleProcessor.processPropertyChange(deviceId, "bluetoothDevices", null); + // 轨迹检测同样需要信号丢失补偿,注入 null 使窗口写入 -999 + trajectoryDetectionProcessor.processPropertyChange(deviceId, "bluetoothDevices", null); } } } @@ -126,8 +132,11 @@ public class CleanRuleProcessorManager { switch (identifier) { case "people_in", "people_out" -> trafficThresholdRuleProcessor.processPropertyChange(deviceId, identifier, value); - case "bluetoothDevices" -> - beaconDetectionRuleProcessor.processPropertyChange(deviceId, identifier, value); + case "bluetoothDevices" -> { + beaconDetectionRuleProcessor.processPropertyChange(deviceId, identifier, value); + // 轨迹检测:独立于保洁到岗检测,匹配所有已知 Beacon + trajectoryDetectionProcessor.processPropertyChange(deviceId, identifier, value); + } default -> { // 其他属性/事件忽略 } 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 new file mode 100644 index 0000000..b6fe4b8 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/service/rule/clean/processor/TrajectoryDetectionProcessor.java @@ -0,0 +1,432 @@ +package com.viewsh.module.iot.service.rule.clean.processor; + +import com.viewsh.framework.common.util.json.JsonUtils; +import com.viewsh.module.iot.core.integration.constants.TrajectoryTopics; +import com.viewsh.module.iot.core.integration.event.trajectory.TrajectoryEnterEvent; +import com.viewsh.module.iot.core.integration.event.trajectory.TrajectoryLeaveEvent; +import com.viewsh.module.iot.dal.dataobject.device.IotDeviceDO; +import com.viewsh.module.iot.dal.dataobject.integration.clean.BeaconPresenceConfig; +import com.viewsh.module.iot.dal.dataobject.integration.clean.TrajectoryTrackingConfig; +import com.viewsh.module.iot.dal.redis.clean.TrajectoryRssiWindowRedisDAO; +import com.viewsh.module.iot.dal.redis.clean.TrajectoryStateRedisDAO; +import com.viewsh.module.iot.service.device.IotDeviceService; +import com.viewsh.module.iot.service.integration.clean.BeaconRegistryService; +import com.viewsh.module.iot.service.integration.clean.BeaconRegistryService.BeaconAreaInfo; +import com.viewsh.module.iot.service.rule.clean.detector.RssiSlidingWindowDetector; +import com.viewsh.module.iot.service.rule.clean.detector.RssiSlidingWindowDetector.AreaState; +import com.viewsh.module.iot.service.rule.clean.detector.RssiSlidingWindowDetector.DetectionResult; +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 com.viewsh.framework.tenant.core.context.TenantContextHolder; + +import java.util.*; +import java.util.concurrent.TimeUnit; + +/** + * 轨迹检测处理器 + *

+ * 工牌每次上报蓝牙数据时,匹配系统中所有已知 Beacon, + * 基于"强进弱出"双阈值算法判断进出区域,自动记录轨迹。 + *

+ * 同一时刻只认一个区域,复用现有 {@link RssiSlidingWindowDetector}。 + *

+ * 核心流程: + *

    + *
  1. 检查设备是否开启轨迹功能
  2. + *
  3. 获取全量 Beacon 注册表
  4. + *
  5. 提取蓝牙列表中所有已知 Beacon 的 RSSI
  6. + *
  7. 更新各区域的 RSSI 滑动窗口
  8. + *
  9. 检测当前区域退出 / 新区域进入
  10. + *
  11. 发布进入/离开事件到 RocketMQ
  12. + *
+ * + * @author lzh + */ +@Component +@Slf4j +public class TrajectoryDetectionProcessor { + + /** + * 设备轨迹功能开关缓存 Key + */ + private static final String DEVICE_ENABLED_KEY_PATTERN = "iot:trajectory:device:enabled:%s"; + private static final int DEVICE_ENABLED_TTL_SECONDS = 3600; // 1小时 + + @Resource + private BeaconRegistryService beaconRegistryService; + + @Resource + private TrajectoryRssiWindowRedisDAO windowRedisDAO; + + @Resource + private TrajectoryStateRedisDAO stateRedisDAO; + + @Resource + private RssiSlidingWindowDetector detector; + + @Resource + private IotDeviceService deviceService; + + @Resource + private RocketMQTemplate rocketMQTemplate; + + @Resource + private StringRedisTemplate stringRedisTemplate; + + /** + * 处理蓝牙属性上报 + * + * @param deviceId 设备ID(工牌) + * @param identifier 属性标识符 + * @param propertyValue 蓝牙设备列表 + */ + public void processPropertyChange(Long deviceId, String identifier, Object propertyValue) { + if (!"bluetoothDevices".equals(identifier)) { + return; + } + + // 1. 检查设备是否开启轨迹功能 + if (!isTrajectoryEnabled(deviceId)) { + return; + } + + // 2. 信号丢失补偿快速路径:propertyValue 为 null 且设备不在任何区域时,直接跳过 + // 避免每次无蓝牙数据的属性上报都查询 Beacon 注册表 + if (propertyValue == null && stateRedisDAO.getCurrentArea(deviceId) == null) { + return; + } + + // 3. 获取 Beacon 注册表 + Map beaconRegistry = beaconRegistryService.getAllBeaconConfigs(); + if (beaconRegistry == null || beaconRegistry.isEmpty()) { + log.debug("[Trajectory] Beacon 注册表为空,跳过检测:deviceId={}", deviceId); + return; + } + + // 4. 构建 areaId → config 索引(避免后续 O(n) 扫描) + Map areaConfigIndex = new HashMap<>(); + for (BeaconAreaInfo info : beaconRegistry.values()) { + areaConfigIndex.putIfAbsent(info.getAreaId(), info.getConfig()); + } + + // 5. 提取蓝牙列表中所有已知 Beacon 的 RSSI + Map matchedBeacons = extractMatchedBeacons(propertyValue, beaconRegistry); + + // 6. 获取当前所在区域 + TrajectoryStateRedisDAO.CurrentAreaInfo currentArea = stateRedisDAO.getCurrentArea(deviceId); + + // 7. 对所有匹配区域更新滑动窗口 + updateAllWindows(deviceId, matchedBeacons, areaConfigIndex, currentArea); + + // 8. 处理区域状态变化 + if (currentArea != null) { + processWithCurrentArea(deviceId, currentArea, matchedBeacons, areaConfigIndex); + } else { + processWithoutCurrentArea(deviceId, matchedBeacons, areaConfigIndex); + } + } + + // ==================== 核心检测逻辑 ==================== + + /** + * 当前在某区域时的处理逻辑 + */ + private void processWithCurrentArea(Long deviceId, + TrajectoryStateRedisDAO.CurrentAreaInfo currentArea, + Map matchedBeacons, + Map areaConfigIndex) { + Long currentAreaId = currentArea.getAreaId(); + + // 6a. 检查当前区域的退出条件 + BeaconPresenceConfig currentConfig = areaConfigIndex.get(currentAreaId); + if (currentConfig != null) { + List window = windowRedisDAO.getWindow(deviceId, currentAreaId); + DetectionResult exitResult = detector.detect( + window, + currentConfig.getEnter(), + currentConfig.getExit(), + AreaState.IN_AREA); + + if (exitResult == DetectionResult.LEAVE_CONFIRMED) { + // 确认离开当前区域 + publishLeaveEvent(deviceId, currentAreaId, currentArea.getBeaconMac(), + "SIGNAL_LOSS", currentArea.getEnterTime()); + stateRedisDAO.clearCurrentArea(deviceId); + windowRedisDAO.clearWindow(deviceId, currentAreaId); + + log.info("[Trajectory] 离开区域:deviceId={}, areaId={}, reason=SIGNAL_LOSS", deviceId, currentAreaId); + + // 离开后,尝试进入新区域 + processWithoutCurrentArea(deviceId, matchedBeacons, areaConfigIndex); + return; + } + } + + // 6b. 当前区域未退出,检查是否有更强区域触发切换 + MatchedBeacon bestCandidate = findBestEnterCandidate(deviceId, matchedBeacons, currentAreaId); + if (bestCandidate != null && !bestCandidate.areaId.equals(currentAreaId)) { + // 区域切换:先离开当前区域,再进入新区域 + publishLeaveEvent(deviceId, currentAreaId, currentArea.getBeaconMac(), + "AREA_SWITCH", currentArea.getEnterTime()); + windowRedisDAO.clearWindow(deviceId, currentAreaId); + + long now = System.currentTimeMillis(); + stateRedisDAO.setCurrentArea(deviceId, bestCandidate.areaId, now, bestCandidate.beaconMac); + windowRedisDAO.clearWindow(deviceId, bestCandidate.areaId); + publishEnterEvent(deviceId, bestCandidate.areaId, bestCandidate.beaconMac, bestCandidate.rssi); + + log.info("[Trajectory] 区域切换:deviceId={}, from={}, to={}", deviceId, currentAreaId, bestCandidate.areaId); + } + } + + /** + * 当前不在任何区域时的处理逻辑 + */ + private void processWithoutCurrentArea(Long deviceId, + Map matchedBeacons, + Map areaConfigIndex) { + MatchedBeacon bestCandidate = findBestEnterCandidate(deviceId, matchedBeacons, null); + if (bestCandidate != null) { + long now = System.currentTimeMillis(); + stateRedisDAO.setCurrentArea(deviceId, bestCandidate.areaId, now, bestCandidate.beaconMac); + windowRedisDAO.clearWindow(deviceId, bestCandidate.areaId); + publishEnterEvent(deviceId, bestCandidate.areaId, bestCandidate.beaconMac, bestCandidate.rssi); + + log.info("[Trajectory] 进入区域:deviceId={}, areaId={}, rssi={}", deviceId, bestCandidate.areaId, bestCandidate.rssi); + } + } + + // ==================== 辅助方法 ==================== + + /** + * 从蓝牙列表中提取所有已知 Beacon 的 RSSI + * 同一区域如果有多个 Beacon 匹配,取 RSSI 最强的 + */ + private Map extractMatchedBeacons(Object bluetoothDevices, + Map beaconRegistry) { + Map result = new HashMap<>(); + + if (bluetoothDevices == null) { + return result; + } + + try { + if (!(bluetoothDevices instanceof List)) { + log.warn("[Trajectory] bluetoothDevices 类型不正确: {}", bluetoothDevices.getClass().getName()); + return result; + } + @SuppressWarnings("unchecked") + List> deviceList = (List>) bluetoothDevices; + + for (Map device : deviceList) { + String mac = (String) device.get("mac"); + if (mac == null) { + continue; + } + + mac = mac.toUpperCase(); + BeaconAreaInfo info = beaconRegistry.get(mac); + if (info == null) { + continue; + } + + Integer rssi = toInt(device.get("rssi")); + if (rssi == null) { + continue; + } + + Long areaId = info.getAreaId(); + MatchedBeacon existing = result.get(areaId); + if (existing == null || rssi > existing.rssi) { + result.put(areaId, new MatchedBeacon(areaId, mac, rssi, info.getConfig())); + } + } + } catch (Exception e) { + log.error("[Trajectory] 解析蓝牙数据失败", e); + } + + return result; + } + + /** + * 更新所有匹配区域的滑动窗口 + * 对于当前所在但未匹配到的区域,注入 -999(信号缺失) + */ + private void updateAllWindows(Long deviceId, + Map matchedBeacons, + Map areaConfigIndex, + TrajectoryStateRedisDAO.CurrentAreaInfo currentArea) { + // 更新匹配到的区域 + for (Map.Entry entry : matchedBeacons.entrySet()) { + MatchedBeacon mb = entry.getValue(); + int maxWindowSize = Math.max( + mb.config.getEnter().getWindowSize(), + mb.config.getExit().getWindowSize()); + windowRedisDAO.updateWindow(deviceId, mb.areaId, mb.rssi, maxWindowSize); + } + + // 当前区域未匹配到 Beacon 时,注入 -999 补偿 + if (currentArea != null && !matchedBeacons.containsKey(currentArea.getAreaId())) { + BeaconPresenceConfig currentConfig = areaConfigIndex.get(currentArea.getAreaId()); + if (currentConfig != null) { + int maxWindowSize = Math.max( + currentConfig.getEnter().getWindowSize(), + currentConfig.getExit().getWindowSize()); + windowRedisDAO.updateWindow(deviceId, currentArea.getAreaId(), -999, maxWindowSize); + } + } + } + + /** + * 找到信号最强且满足进入条件的候选区域 + * + * @param excludeAreaId 排除的区域ID(当前所在区域),null 表示不排除 + */ + private MatchedBeacon findBestEnterCandidate(Long deviceId, + Map matchedBeacons, + Long excludeAreaId) { + MatchedBeacon best = null; + + for (Map.Entry entry : matchedBeacons.entrySet()) { + Long areaId = entry.getKey(); + if (areaId.equals(excludeAreaId)) { + continue; + } + + MatchedBeacon mb = entry.getValue(); + List window = windowRedisDAO.getWindow(deviceId, areaId); + DetectionResult result = detector.detect( + window, + mb.config.getEnter(), + mb.config.getExit(), + AreaState.OUT_AREA); + + if (result == DetectionResult.ARRIVE_CONFIRMED) { + if (best == null || mb.rssi > best.rssi) { + best = mb; + } + } + } + + return best; + } + + // ==================== 功能开关 ==================== + + /** + * 检查设备是否开启轨迹功能 + * 结果缓存1小时 + */ + private boolean isTrajectoryEnabled(Long deviceId) { + String cacheKey = String.format(DEVICE_ENABLED_KEY_PATTERN, deviceId); + + // 先读缓存 + try { + String cached = stringRedisTemplate.opsForValue().get(cacheKey); + if (cached != null) { + return "true".equals(cached); + } + } catch (Exception e) { + log.warn("[Trajectory] 读取功能开关缓存失败:deviceId={}", deviceId, e); + } + + // 缓存未命中,从设备配置读取 + boolean enabled = false; + try { + IotDeviceDO device = deviceService.getDeviceFromCache(deviceId); + if (device != null && device.getConfig() != null) { + @SuppressWarnings("unchecked") + Map configMap = JsonUtils.parseObject(device.getConfig(), Map.class); + if (configMap != null && configMap.containsKey("trajectoryTracking")) { + Object ttObj = configMap.get("trajectoryTracking"); + TrajectoryTrackingConfig ttConfig = JsonUtils.parseObject( + JsonUtils.toJsonString(ttObj), TrajectoryTrackingConfig.class); + enabled = ttConfig != null && Boolean.TRUE.equals(ttConfig.getEnabled()); + } + } + } catch (Exception e) { + log.error("[Trajectory] 获取轨迹配置失败:deviceId={}", deviceId, e); + } + + // 写入缓存 + try { + stringRedisTemplate.opsForValue().set(cacheKey, String.valueOf(enabled), + DEVICE_ENABLED_TTL_SECONDS, TimeUnit.SECONDS); + } catch (Exception e) { + log.warn("[Trajectory] 写入功能开关缓存失败:deviceId={}", deviceId, e); + } + + return enabled; + } + + // ==================== 事件发布 ==================== + + private void publishEnterEvent(Long deviceId, Long areaId, String beaconMac, Integer enterRssi) { + try { + IotDeviceDO device = deviceService.getDeviceFromCache(deviceId); + TrajectoryEnterEvent event = TrajectoryEnterEvent.builder() + .deviceId(deviceId) + .deviceName(device != null ? device.getDeviceName() : null) + .nickname(device != null ? device.getNickname() : null) + .areaId(areaId) + .beaconMac(beaconMac) + .enterRssi(enterRssi) + .tenantId(TenantContextHolder.getTenantId()) + .build(); + + rocketMQTemplate.syncSend(TrajectoryTopics.TRAJECTORY_ENTER, + MessageBuilder.withPayload(event).build()); + + log.info("[Trajectory] 发布进入事件:eventId={}, deviceId={}, areaId={}", + event.getEventId(), deviceId, areaId); + } catch (Exception e) { + log.error("[Trajectory] 发布进入事件失败:deviceId={}, areaId={}", deviceId, areaId, e); + } + } + + private void publishLeaveEvent(Long deviceId, Long areaId, String beaconMac, + String leaveReason, Long enterTimestamp) { + try { + IotDeviceDO device = deviceService.getDeviceFromCache(deviceId); + TrajectoryLeaveEvent event = TrajectoryLeaveEvent.builder() + .deviceId(deviceId) + .deviceName(device != null ? device.getDeviceName() : null) + .nickname(device != null ? device.getNickname() : null) + .areaId(areaId) + .beaconMac(beaconMac) + .leaveReason(leaveReason) + .enterTimestamp(enterTimestamp) + .tenantId(TenantContextHolder.getTenantId()) + .build(); + + rocketMQTemplate.syncSend(TrajectoryTopics.TRAJECTORY_LEAVE, + MessageBuilder.withPayload(event).build()); + + log.info("[Trajectory] 发布离开事件:eventId={}, deviceId={}, areaId={}, reason={}", + event.getEventId(), deviceId, areaId, leaveReason); + } catch (Exception e) { + log.error("[Trajectory] 发布离开事件失败:deviceId={}, areaId={}", deviceId, areaId, e); + } + } + + private Integer toInt(Object value) { + if (value instanceof Integer) { + return (Integer) value; + } + if (value instanceof Number) { + return ((Number) value).intValue(); + } + return null; + } + + /** + * 匹配到的 Beacon 信息 + */ + private record MatchedBeacon(Long areaId, String beaconMac, Integer rssi, BeaconPresenceConfig config) { + } +} diff --git a/viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/api/area/AreaDeviceApi.java b/viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/api/area/AreaDeviceApi.java index c4b23cc..5c88374 100644 --- a/viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/api/area/AreaDeviceApi.java +++ b/viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/api/area/AreaDeviceApi.java @@ -39,14 +39,20 @@ public interface AreaDeviceApi { @PathVariable("areaId") Long areaId ); - @GetMapping(PREFIX + "/{areaId}/devices") - @Operation(summary = "查询区域设备列表(按类型)") - CommonResult> getDevicesByAreaAndType( - @Parameter(description = "区域ID", required = true, example = "1302") - @PathVariable("areaId") Long areaId, - @Parameter(description = "关联类型(BADGE/TRAFFIC_COUNTER/BEACON)", required = false, example = "BADGE") - @RequestParam(value = "relationType", required = false) String relationType - ); + @GetMapping(PREFIX + "/{areaId}/devices") + @Operation(summary = "查询区域设备列表(按类型)") + CommonResult> getDevicesByAreaAndType( + @Parameter(description = "区域ID", required = true, example = "1302") + @PathVariable("areaId") Long areaId, + @Parameter(description = "关联类型(BADGE/TRAFFIC_COUNTER/BEACON)", required = false, example = "BADGE") + @RequestParam(value = "relationType", required = false) String relationType + ); + + // ==================== 全量查询 ==================== + + @GetMapping(PREFIX + "/beacons/all") + @Operation(summary = "查询所有启用的Beacon设备(轨迹检测用)") + CommonResult> getAllEnabledBeacons(); // ==================== 按设备查询 ==================== diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/area/AreaDeviceService.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/area/AreaDeviceService.java index 04f646d..f7dd715 100644 --- a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/area/AreaDeviceService.java +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/area/AreaDeviceService.java @@ -82,6 +82,23 @@ public interface AreaDeviceService { */ List getDeviceIdsByAreaAndType(Long areaId, String relationType); + /** + * 查询所有启用的指定类型设备关联 + * + * @param relationType 关联类型(BADGE/BEACON/TRAFFIC_COUNTER) + * @return 所有启用的指定类型关联关系 + */ + List listAllByType(String relationType); + + /** + * 查询所有启用的 Beacon 设备关联 + *

+ * 用于轨迹检测功能,获取全量 Beacon 注册表 + * + * @return 所有启用的 Beacon 类型关联关系 + */ + List listAllEnabledBeacons(); + /** * 初始化区域设备配置缓存 *

diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/area/AreaDeviceServiceImpl.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/area/AreaDeviceServiceImpl.java index 8c3f2e4..dc82847 100644 --- a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/area/AreaDeviceServiceImpl.java +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/area/AreaDeviceServiceImpl.java @@ -163,6 +163,16 @@ public class AreaDeviceServiceImpl implements AreaDeviceService, InitializingBea .collect(Collectors.toList()); } + @Override + public List listAllByType(String relationType) { + return relationMapper.selectListByAreaIdAndRelationType(null, relationType); + } + + @Override + public List listAllEnabledBeacons() { + return listAllByType("BEACON"); + } + @Override public void initConfigCache() { log.info("[AreaDevice] 开始初始化区域设备配置缓存..."); diff --git a/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/area/AreaDeviceController.java b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/area/AreaDeviceController.java index afca56c..d6468a4 100644 --- a/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/area/AreaDeviceController.java +++ b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/area/AreaDeviceController.java @@ -37,6 +37,13 @@ public class AreaDeviceController { @Resource private AreaDeviceService areaDeviceService; + @GetMapping("/beacons/all") + @Operation(summary = "查询所有启用的Beacon设备(轨迹检测用)") + public CommonResult> getAllEnabledBeacons() { + List relations = areaDeviceService.listAllEnabledBeacons(); + return success(BeanUtils.toBean(relations, AreaDeviceDTO.class)); + } + @GetMapping("/{areaId}/badges") @Operation(summary = "查询区域的工牌设备列表") public CommonResult> getBadgesByArea( @@ -45,21 +52,21 @@ public class AreaDeviceController { return success(BeanUtils.toBean(relations, AreaDeviceDTO.class)); } - @GetMapping("/{areaId}/devices") - @Operation(summary = "查询区域设备列表(按类型)") - public CommonResult> getDevicesByAreaAndType( - @PathVariable("areaId") Long areaId, - @RequestParam(value = "relationType", required = false) String relationType) { - - List relations; - if (relationType != null) { - relations = areaDeviceService.listByAreaIdAndType(areaId, relationType); - } else { - relations = areaDeviceService.listByAreaId(areaId); - } - - return success(BeanUtils.toBean(relations, AreaDeviceDTO.class)); - } + @GetMapping("/{areaId}/devices") + @Operation(summary = "查询区域设备列表(按类型)") + public CommonResult> getDevicesByAreaAndType( + @PathVariable("areaId") Long areaId, + @RequestParam(value = "relationType", required = false) String relationType) { + + List relations; + if (relationType != null) { + relations = areaDeviceService.listByAreaIdAndType(areaId, relationType); + } else { + relations = areaDeviceService.listByAreaId(areaId); + } + + return success(BeanUtils.toBean(relations, AreaDeviceDTO.class)); + } @GetMapping("/device/{deviceId}/relation") @Operation(summary = "查询设备的关联关系")