From 11dcb57ff31a05aad95c74edd32d6d04c4585a01 Mon Sep 17 00:00:00 2001 From: lzh Date: Tue, 31 Mar 2026 22:53:06 +0800 Subject: [PATCH 01/16] =?UTF-8?q?feat(trajectory):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E8=BD=A8=E8=BF=B9=E6=A3=80=E6=B5=8B=E4=B8=8E=20Beacon=20?= =?UTF-8?q?=E6=B3=A8=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 00000000..14ff1eba --- /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 00000000..40933e80 --- /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 00000000..2b849bb3 --- /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 00000000..228baea4 --- /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 00000000..e5015617 --- /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 00000000..9d4744fa --- /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 00000000..8df8c6a5 --- /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 00000000..518a67c9 --- /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 00000000..c38ec37d --- /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 00000000..71ac93c6 --- /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 00000000..2e1d4fef --- /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 b46274fa..acf13bf1 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 00000000..b6fe4b8f --- /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 c4b23cc2..5c883743 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 04f646d5..f7dd7158 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 8c3f2e40..dc82847d 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 afca56c5..d6468a49 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 = "查询设备的关联关系") From bf5aa21648367ae0a972644162504778365dea3d Mon Sep 17 00:00:00 2001 From: lzh Date: Tue, 31 Mar 2026 22:56:18 +0800 Subject: [PATCH 02/16] =?UTF-8?q?feat(trajectory):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E8=BD=A8=E8=BF=B9=E4=BA=8B=E4=BB=B6=E6=B6=88=E8=B4=B9=E4=B8=8E?= =?UTF-8?q?=E8=90=BD=E5=BA=93=E6=A8=A1=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 ops_device_trajectory 表及轨迹数据对象、Mapper\n- 消费 trajectory-enter / trajectory-leave 事件并做幂等处理\n- 落地设备进入/离开区域记录,补充停留时长与离开原因字段\n- 在服务层封装轨迹写入、关闭未离场记录等核心逻辑 --- sql/mysql/ops_device_trajectory.sql | 37 +++ .../consumer/TrajectoryEnterEventHandler.java | 89 ++++++ .../consumer/TrajectoryLeaveEventHandler.java | 87 ++++++ .../dto/TrajectoryEnterEventDTO.java | 65 ++++ .../dto/TrajectoryLeaveEventDTO.java | 72 +++++ .../trajectory/DeviceTrajectoryService.java | 65 ++++ .../DeviceTrajectoryServiceImpl.java | 284 ++++++++++++++++++ .../module/ops/enums/LeaveReasonEnum.java | 22 ++ .../trajectory/OpsDeviceTrajectoryDO.java | 104 +++++++ .../trajectory/OpsDeviceTrajectoryMapper.java | 83 +++++ 10 files changed, 908 insertions(+) create mode 100644 sql/mysql/ops_device_trajectory.sql create mode 100644 viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/integration/consumer/TrajectoryEnterEventHandler.java create mode 100644 viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/integration/consumer/TrajectoryLeaveEventHandler.java create mode 100644 viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/integration/dto/TrajectoryEnterEventDTO.java create mode 100644 viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/integration/dto/TrajectoryLeaveEventDTO.java create mode 100644 viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/trajectory/DeviceTrajectoryService.java create mode 100644 viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/trajectory/DeviceTrajectoryServiceImpl.java create mode 100644 viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/enums/LeaveReasonEnum.java create mode 100644 viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/dataobject/trajectory/OpsDeviceTrajectoryDO.java create mode 100644 viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/mysql/trajectory/OpsDeviceTrajectoryMapper.java diff --git a/sql/mysql/ops_device_trajectory.sql b/sql/mysql/ops_device_trajectory.sql new file mode 100644 index 00000000..b080b7ba --- /dev/null +++ b/sql/mysql/ops_device_trajectory.sql @@ -0,0 +1,37 @@ +-- ============================================= +-- 设备轨迹记录表 +-- 记录工牌设备进出各区域的轨迹(进入时创建,离开时更新) +-- ============================================= + +CREATE TABLE IF NOT EXISTS `ops_device_trajectory` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', + `device_id` BIGINT NOT NULL COMMENT '工牌设备ID', + `device_name` VARCHAR(64) DEFAULT NULL COMMENT '设备名称(冗余)', + `nickname` VARCHAR(64) DEFAULT NULL COMMENT '设备备注名称(冗余)', + `person_id` BIGINT DEFAULT NULL COMMENT '人员ID(预留)', + `person_name` VARCHAR(64) DEFAULT NULL COMMENT '人员名称(预留)', + `area_id` BIGINT NOT NULL COMMENT '区域ID', + `area_name` VARCHAR(128) DEFAULT NULL COMMENT '区域名称(冗余)', + `beacon_mac` VARCHAR(64) DEFAULT NULL COMMENT '匹配的Beacon MAC', + `enter_time` DATETIME NOT NULL COMMENT '进入时间', + `leave_time` DATETIME DEFAULT NULL COMMENT '离开时间', + `duration_seconds` INT DEFAULT NULL COMMENT '停留时长(秒)', + `leave_reason` VARCHAR(32) DEFAULT NULL COMMENT '离开原因: SIGNAL_LOSS/AREA_SWITCH/DEVICE_OFFLINE', + `enter_rssi` INT DEFAULT NULL COMMENT '进入时RSSI', + `tenant_id` BIGINT NOT NULL DEFAULT 0 COMMENT '租户ID', + `creator` VARCHAR(64) DEFAULT '' COMMENT '创建者', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updater` VARCHAR(64) DEFAULT '' COMMENT '更新者', + `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `deleted` BIT(1) NOT NULL DEFAULT b'0' COMMENT '是否删除', + PRIMARY KEY (`id`), + INDEX `idx_device_enter` (`device_id`, `enter_time`), + INDEX `idx_area_enter` (`area_id`, `enter_time`), + INDEX `idx_device_area` (`device_id`, `area_id`), + INDEX `idx_tenant` (`tenant_id`), + INDEX `idx_device_area_leave` (`device_id`, `area_id`, `leave_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='设备轨迹记录'; + +-- 优化:为 selectOpenRecord 查询(device_id + area_id + leave_time IS NULL)添加索引 +-- 注:已有数据的线上环境需单独执行以下 ALTER(如数据量大请在低峰期执行): +-- ALTER TABLE `ops_device_trajectory` ADD INDEX `idx_device_area_leave` (`device_id`, `area_id`, `leave_time`); diff --git a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/integration/consumer/TrajectoryEnterEventHandler.java b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/integration/consumer/TrajectoryEnterEventHandler.java new file mode 100644 index 00000000..a1363dbb --- /dev/null +++ b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/integration/consumer/TrajectoryEnterEventHandler.java @@ -0,0 +1,89 @@ +package com.viewsh.module.ops.environment.integration.consumer; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.viewsh.module.ops.environment.integration.dto.TrajectoryEnterEventDTO; +import com.viewsh.module.ops.environment.service.trajectory.DeviceTrajectoryService; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.apache.rocketmq.spring.annotation.ConsumeMode; +import org.apache.rocketmq.spring.annotation.RocketMQMessageListener; +import org.apache.rocketmq.spring.core.RocketMQListener; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; + +/** + * 轨迹进入区域事件消费者 + *

+ * 订阅 IoT 模块发布的轨迹进入事件,创建轨迹记录 + * + * @author lzh + */ +@Slf4j +@Component +@RocketMQMessageListener( + topic = "trajectory-enter", + consumerGroup = "ops-trajectory-enter-group", + consumeMode = ConsumeMode.CONCURRENTLY, + selectorExpression = "*", + accessKey = "${rocketmq.consumer.access-key:}", + secretKey = "${rocketmq.consumer.secret-key:}" +) +public class TrajectoryEnterEventHandler implements RocketMQListener { + + @Resource + private ObjectMapper objectMapper; + + @Resource + private IntegrationEventDeduplicationService deduplicationService; + + @Resource + private DeviceTrajectoryService trajectoryService; + + @Override + public void onMessage(String message) { + try { + TrajectoryEnterEventDTO event = objectMapper.readValue(message, TrajectoryEnterEventDTO.class); + + // 幂等性检查 + if (!deduplicationService.tryConsume(event.getEventId())) { + log.debug("[TrajectoryEnterHandler] 重复消息,跳过:eventId={}", event.getEventId()); + return; + } + + log.info("[TrajectoryEnterHandler] 收到进入事件:eventId={}, deviceId={}, areaId={}", + event.getEventId(), event.getDeviceId(), event.getAreaId()); + + // 解析事件时间 + LocalDateTime enterTime = parseEventTime(event.getEventTime()); + + // 创建轨迹记录 + trajectoryService.recordEnter( + event.getDeviceId(), + event.getDeviceName(), + event.getNickname(), + event.getAreaId(), + event.getBeaconMac(), + event.getEnterRssi(), + enterTime); + + } catch (Exception e) { + log.error("[TrajectoryEnterHandler] 消息处理失败:message={}", message, e); + throw new RuntimeException("轨迹进入事件处理失败", e); + } + } + + private LocalDateTime parseEventTime(String eventTime) { + if (eventTime == null || eventTime.isEmpty()) { + return LocalDateTime.now(); + } + try { + return LocalDateTime.parse(eventTime, DateTimeFormatter.ISO_LOCAL_DATE_TIME); + } catch (DateTimeParseException e) { + log.warn("[TrajectoryEnterHandler] 事件时间解析失败,使用当前时间:eventTime={}", eventTime, e); + return LocalDateTime.now(); + } + } +} diff --git a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/integration/consumer/TrajectoryLeaveEventHandler.java b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/integration/consumer/TrajectoryLeaveEventHandler.java new file mode 100644 index 00000000..6499c882 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/integration/consumer/TrajectoryLeaveEventHandler.java @@ -0,0 +1,87 @@ +package com.viewsh.module.ops.environment.integration.consumer; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.viewsh.module.ops.environment.integration.dto.TrajectoryLeaveEventDTO; +import com.viewsh.module.ops.environment.service.trajectory.DeviceTrajectoryService; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.apache.rocketmq.spring.annotation.ConsumeMode; +import org.apache.rocketmq.spring.annotation.RocketMQMessageListener; +import org.apache.rocketmq.spring.core.RocketMQListener; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; + +/** + * 轨迹离开区域事件消费者 + *

+ * 订阅 IoT 模块发布的轨迹离开事件,更新轨迹记录的离开信息 + * + * @author lzh + */ +@Slf4j +@Component +@RocketMQMessageListener( + topic = "trajectory-leave", + consumerGroup = "ops-trajectory-leave-group", + consumeMode = ConsumeMode.CONCURRENTLY, + selectorExpression = "*", + accessKey = "${rocketmq.consumer.access-key:}", + secretKey = "${rocketmq.consumer.secret-key:}" +) +public class TrajectoryLeaveEventHandler implements RocketMQListener { + + @Resource + private ObjectMapper objectMapper; + + @Resource + private IntegrationEventDeduplicationService deduplicationService; + + @Resource + private DeviceTrajectoryService trajectoryService; + + @Override + public void onMessage(String message) { + try { + TrajectoryLeaveEventDTO event = objectMapper.readValue(message, TrajectoryLeaveEventDTO.class); + + // 幂等性检查 + if (!deduplicationService.tryConsume(event.getEventId())) { + log.debug("[TrajectoryLeaveHandler] 重复消息,跳过:eventId={}", event.getEventId()); + return; + } + + log.info("[TrajectoryLeaveHandler] 收到离开事件:eventId={}, deviceId={}, areaId={}, reason={}", + event.getEventId(), event.getDeviceId(), event.getAreaId(), event.getLeaveReason()); + + // 解析事件时间 + LocalDateTime leaveTime = parseEventTime(event.getEventTime()); + + // 更新轨迹记录 + trajectoryService.recordLeave( + event.getDeviceId(), + event.getAreaId(), + event.getLeaveReason(), + event.getEnterTimestamp(), + leaveTime); + + } catch (Exception e) { + log.error("[TrajectoryLeaveHandler] 消息处理失败:message={}", message, e); + throw new RuntimeException("轨迹离开事件处理失败", e); + } + } + + private LocalDateTime parseEventTime(String eventTime) { + if (eventTime == null || eventTime.isEmpty()) { + return LocalDateTime.now(); + } + try { + return LocalDateTime.parse(eventTime, DateTimeFormatter.ISO_LOCAL_DATE_TIME); + } catch (DateTimeParseException e) { + log.warn("[TrajectoryLeaveHandler] 事件时间解析失败,使用当前时间:eventTime={}", eventTime, e); + return LocalDateTime.now(); + } + } +} diff --git a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/integration/dto/TrajectoryEnterEventDTO.java b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/integration/dto/TrajectoryEnterEventDTO.java new file mode 100644 index 00000000..26ada6f1 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/integration/dto/TrajectoryEnterEventDTO.java @@ -0,0 +1,65 @@ +package com.viewsh.module.ops.environment.integration.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 轨迹进入区域事件 DTO + *

+ * 由 IoT 模块发布,Ops 模块消费 + * + * @author lzh + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TrajectoryEnterEventDTO { + + /** + * 事件ID(UUID,用于幂等性控制) + */ + private String eventId; + + /** + * 设备ID(工牌) + */ + private Long deviceId; + + /** + * 设备名称 + */ + private String deviceName; + + /** + * 设备备注名称 + */ + private String nickname; + + /** + * 区域ID + */ + private Long areaId; + + /** + * 匹配的 Beacon MAC 地址 + */ + private String beaconMac; + + /** + * 进入时的 RSSI 值 + */ + private Integer enterRssi; + + /** + * 事件时间(ISO 格式) + */ + private String eventTime; + + /** + * 租户ID + */ + private Long tenantId; +} diff --git a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/integration/dto/TrajectoryLeaveEventDTO.java b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/integration/dto/TrajectoryLeaveEventDTO.java new file mode 100644 index 00000000..76fb87c8 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/integration/dto/TrajectoryLeaveEventDTO.java @@ -0,0 +1,72 @@ +package com.viewsh.module.ops.environment.integration.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 轨迹离开区域事件 DTO + *

+ * 由 IoT 模块发布,Ops 模块消费 + * + * @author lzh + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TrajectoryLeaveEventDTO { + + /** + * 事件ID(UUID,用于幂等性控制) + */ + private String eventId; + + /** + * 设备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; + + /** + * 进入时间戳(毫秒),用于匹配轨迹记录 + */ + private Long enterTimestamp; + + /** + * 事件时间(ISO 格式) + */ + private String eventTime; + + /** + * 租户ID + */ + private Long tenantId; +} diff --git a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/trajectory/DeviceTrajectoryService.java b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/trajectory/DeviceTrajectoryService.java new file mode 100644 index 00000000..ca011c24 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/trajectory/DeviceTrajectoryService.java @@ -0,0 +1,65 @@ +package com.viewsh.module.ops.environment.service.trajectory; + +import com.viewsh.framework.common.pojo.PageResult; +import com.viewsh.module.ops.service.trajectory.dto.TrajectoryPageReqDTO; +import com.viewsh.module.ops.service.trajectory.dto.TrajectoryRespDTO; +import com.viewsh.module.ops.service.trajectory.dto.TrajectorySummaryDTO; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +/** + * 设备轨迹服务 + *

+ * 负责轨迹记录的创建和更新 + * + * @author lzh + */ +public interface DeviceTrajectoryService { + + /** + * 记录设备进入区域 + * + * @param deviceId 设备ID + * @param deviceName 设备名称 + * @param nickname 设备备注名称 + * @param areaId 区域ID + * @param beaconMac Beacon MAC + * @param enterRssi 进入时 RSSI + * @param enterTime 进入时间 + */ + void recordEnter(Long deviceId, String deviceName, String nickname, Long areaId, + String beaconMac, Integer enterRssi, LocalDateTime enterTime); + + /** + * 记录设备离开区域 + * + * @param deviceId 设备ID + * @param areaId 区域ID + * @param leaveReason 离开原因 + * @param enterTimestamp 进入时间戳(毫秒),用于匹配记录 + * @param leaveTime 离开时间 + */ + void recordLeave(Long deviceId, Long areaId, String leaveReason, + Long enterTimestamp, LocalDateTime leaveTime); + + /** + * 分页查询轨迹记录 + */ + PageResult getTrajectoryPage(TrajectoryPageReqDTO req); + + /** + * 查询某设备某天的轨迹时间线 + */ + List getTimeline(Long deviceId, LocalDate date); + + /** + * 查询轨迹统计摘要 + * + * @param deviceId 设备ID(必填) + * @param date 日期(必填) + */ + TrajectorySummaryDTO getSummary(Long deviceId, LocalDate date); + +} diff --git a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/trajectory/DeviceTrajectoryServiceImpl.java b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/trajectory/DeviceTrajectoryServiceImpl.java new file mode 100644 index 00000000..fa861418 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/trajectory/DeviceTrajectoryServiceImpl.java @@ -0,0 +1,284 @@ +package com.viewsh.module.ops.environment.service.trajectory; + +import com.viewsh.framework.common.pojo.PageResult; +import com.viewsh.framework.common.util.object.BeanUtils; +import com.viewsh.module.ops.dal.dataobject.area.OpsBusAreaDO; +import com.viewsh.module.ops.dal.dataobject.trajectory.OpsDeviceTrajectoryDO; +import com.viewsh.module.ops.dal.mysql.area.OpsBusAreaMapper; +import com.viewsh.module.ops.dal.mysql.trajectory.OpsDeviceTrajectoryMapper; +import com.viewsh.module.ops.service.trajectory.dto.TrajectoryPageReqDTO; +import com.viewsh.module.ops.service.trajectory.dto.TrajectoryRespDTO; +import com.viewsh.module.ops.service.trajectory.dto.TrajectorySummaryDTO; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.*; +import java.util.stream.Collectors; + +/** + * 设备轨迹服务实现 + * + * @author lzh + */ +@Slf4j +@Service +public class DeviceTrajectoryServiceImpl implements DeviceTrajectoryService { + + @Resource + private OpsDeviceTrajectoryMapper trajectoryMapper; + + @Resource + private OpsBusAreaMapper areaMapper; + + // ==================== 写入方法 ==================== + + @Override + @Transactional(rollbackFor = Exception.class) + public void recordEnter(Long deviceId, String deviceName, String nickname, Long areaId, + String beaconMac, Integer enterRssi, LocalDateTime enterTime) { + // 使用 SELECT ... FOR UPDATE 防止并发创建重复记录 + OpsDeviceTrajectoryDO openRecord = trajectoryMapper.selectOpenRecordForUpdate(deviceId, areaId); + if (openRecord != null) { + log.warn("[Trajectory] 设备已有未关闭的轨迹记录,跳过创建:deviceId={}, areaId={}, existingId={}", + deviceId, areaId, openRecord.getId()); + return; + } + + OpsDeviceTrajectoryDO record = OpsDeviceTrajectoryDO.builder() + .deviceId(deviceId) + .deviceName(deviceName) + .nickname(nickname) + .areaId(areaId) + .beaconMac(beaconMac) + .enterRssi(enterRssi) + .enterTime(enterTime) + .build(); + + // 填充区域名称冗余字段 + fillAreaName(record); + + trajectoryMapper.insert(record); + log.info("[Trajectory] 创建轨迹记录:id={}, deviceId={}, areaId={}, enterTime={}", + record.getId(), deviceId, areaId, enterTime); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void recordLeave(Long deviceId, Long areaId, String leaveReason, + Long enterTimestamp, LocalDateTime leaveTime) { + // 优先使用 enterTimestamp 精确匹配,避免关闭错误的记录 + OpsDeviceTrajectoryDO record = null; + if (enterTimestamp != null) { + LocalDateTime enterTime = LocalDateTime.ofInstant( + Instant.ofEpochMilli(enterTimestamp), ZoneId.systemDefault()); + record = trajectoryMapper.selectOpenRecordByEnterTimeForUpdate(deviceId, areaId, enterTime); + } + // 降级:按 deviceId + areaId 查询最近的未关闭记录 + if (record == null) { + record = trajectoryMapper.selectOpenRecordForUpdate(deviceId, areaId); + } + if (record == null) { + log.warn("[Trajectory] 未找到匹配的轨迹记录,跳过更新:deviceId={}, areaId={}, enterTimestamp={}", + deviceId, areaId, enterTimestamp); + return; + } + + int durationSeconds = 0; + if (record.getEnterTime() != null && leaveTime != null) { + durationSeconds = (int) Duration.between(record.getEnterTime(), leaveTime).getSeconds(); + if (durationSeconds < 0) { + durationSeconds = 0; + } + } + + record.setLeaveTime(leaveTime); + record.setDurationSeconds(durationSeconds); + record.setLeaveReason(leaveReason); + + trajectoryMapper.updateById(record); + log.info("[Trajectory] 更新轨迹记录(离开):id={}, deviceId={}, areaId={}, duration={}s, reason={}", + record.getId(), deviceId, areaId, durationSeconds, leaveReason); + } + + // ==================== 查询方法 ==================== + + @Override + public PageResult getTrajectoryPage(TrajectoryPageReqDTO req) { + PageResult pageResult = trajectoryMapper.selectPage(req); + PageResult result = BeanUtils.toBean(pageResult, TrajectoryRespDTO.class); + enrichWithAreaInfo(result.getList()); + return result; + } + + @Override + public List getTimeline(Long deviceId, LocalDate date) { + List list = trajectoryMapper.selectTimeline(deviceId, date); + List result = BeanUtils.toBean(list, TrajectoryRespDTO.class); + enrichWithAreaInfo(result); + return result; + } + + @Override + public TrajectorySummaryDTO getSummary(Long deviceId, LocalDate date) { + List list = trajectoryMapper.selectTimeline(deviceId, date); + if (list.isEmpty()) { + return TrajectorySummaryDTO.builder() + .totalRecords(0L) + .completedRecords(0L) + .coveredAreaCount(0L) + .totalDurationSeconds(0L) + .avgDurationSeconds(0L) + .maxDurationSeconds(0L) + .build(); + } + + long totalRecords = list.size(); + long coveredAreaCount = list.stream() + .map(OpsDeviceTrajectoryDO::getAreaId) + .distinct() + .count(); + + // 只统计已关闭(有 durationSeconds)的记录 + List durations = list.stream() + .map(OpsDeviceTrajectoryDO::getDurationSeconds) + .filter(Objects::nonNull) + .toList(); + + long completedRecords = durations.size(); + long totalDuration = durations.stream().mapToLong(Integer::longValue).sum(); + long avgDuration = durations.isEmpty() ? 0 : totalDuration / durations.size(); + long maxDuration = durations.stream().mapToInt(Integer::intValue).max().orElse(0); + + return TrajectorySummaryDTO.builder() + .totalRecords(totalRecords) + .completedRecords(completedRecords) + .coveredAreaCount(coveredAreaCount) + .totalDurationSeconds(totalDuration) + .avgDurationSeconds(avgDuration) + .maxDurationSeconds(maxDuration) + .build(); + } + + // ==================== 内部方法 ==================== + + /** + * 填充单条记录的区域名称 + */ + private void fillAreaName(OpsDeviceTrajectoryDO record) { + if (record.getAreaId() == null) { + return; + } + try { + OpsBusAreaDO area = areaMapper.selectById(record.getAreaId()); + if (area != null) { + record.setAreaName(area.getAreaName()); + } + } catch (Exception e) { + log.warn("[Trajectory] 查询区域名称失败:areaId={}", record.getAreaId(), e); + } + } + + /** + * 批量填充轨迹记录的区域信息(areaName、buildingName、floorNo) + */ + private void enrichWithAreaInfo(List list) { + if (list == null || list.isEmpty()) { + return; + } + + // 收集所有 areaId + Set areaIds = list.stream() + .map(TrajectoryRespDTO::getAreaId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + + if (areaIds.isEmpty()) { + return; + } + + // 批量查询区域 + List areas = areaMapper.selectBatchIds(areaIds); + Map areaMap = areas.stream() + .collect(Collectors.toMap(OpsBusAreaDO::getId, a -> a, (a, b) -> a)); + + // 收集需要查询的父级 ID(楼栋信息) + Set parentIds = new HashSet<>(); + for (OpsBusAreaDO area : areas) { + if (area.getParentPath() != null && !area.getParentPath().isEmpty()) { + // parentPath 格式: "/1/2/3",提取所有祖先 ID + for (String idStr : area.getParentPath().split("/")) { + if (!idStr.isEmpty()) { + try { + parentIds.add(Long.parseLong(idStr)); + } catch (NumberFormatException ignored) { + } + } + } + } + if (area.getParentId() != null) { + parentIds.add(area.getParentId()); + } + } + parentIds.removeAll(areaIds); // 排除已查过的 + + // 批量查询父级区域 + Map allAreaMap = new HashMap<>(areaMap); + if (!parentIds.isEmpty()) { + List parentAreas = areaMapper.selectBatchIds(parentIds); + for (OpsBusAreaDO pa : parentAreas) { + allAreaMap.put(pa.getId(), pa); + } + } + + // 填充每条记录 + for (TrajectoryRespDTO dto : list) { + if (dto.getAreaId() == null) { + continue; + } + OpsBusAreaDO area = areaMap.get(dto.getAreaId()); + if (area == null) { + continue; + } + dto.setAreaName(area.getAreaName()); + dto.setFloorNo(area.getFloorNo()); + dto.setBuildingName(findBuildingName(area, allAreaMap)); + + // 如果 floorNo 为空,尝试从 FLOOR 类型的父级获取 + if (dto.getFloorNo() == null && area.getParentId() != null) { + OpsBusAreaDO parent = allAreaMap.get(area.getParentId()); + if (parent != null && "FLOOR".equals(parent.getAreaType())) { + dto.setFloorNo(parent.getFloorNo()); + } + } + } + } + + /** + * 沿 parentPath 向上查找 BUILDING 类型的祖先区域名称 + */ + private String findBuildingName(OpsBusAreaDO area, Map allAreaMap) { + if (area.getParentPath() == null) { + return null; + } + for (String idStr : area.getParentPath().split("/")) { + if (idStr.isEmpty()) { + continue; + } + try { + OpsBusAreaDO ancestor = allAreaMap.get(Long.parseLong(idStr)); + if (ancestor != null && "BUILDING".equals(ancestor.getAreaType())) { + return ancestor.getAreaName(); + } + } catch (NumberFormatException ignored) { + } + } + return null; + } +} diff --git a/viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/enums/LeaveReasonEnum.java b/viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/enums/LeaveReasonEnum.java new file mode 100644 index 00000000..6a29f7b0 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/enums/LeaveReasonEnum.java @@ -0,0 +1,22 @@ +package com.viewsh.module.ops.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 轨迹离开原因枚举 + * + * @author lzh + */ +@Getter +@AllArgsConstructor +public enum LeaveReasonEnum { + + SIGNAL_LOSS("SIGNAL_LOSS", "信号丢失"), + AREA_SWITCH("AREA_SWITCH", "切换到其他区域"), + DEVICE_OFFLINE("DEVICE_OFFLINE", "设备离线"); + + private final String reason; + private final String description; + +} diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/dataobject/trajectory/OpsDeviceTrajectoryDO.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/dataobject/trajectory/OpsDeviceTrajectoryDO.java new file mode 100644 index 00000000..f5c65dbf --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/dataobject/trajectory/OpsDeviceTrajectoryDO.java @@ -0,0 +1,104 @@ +package com.viewsh.module.ops.dal.dataobject.trajectory; + +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.viewsh.framework.tenant.core.db.TenantBaseDO; +import lombok.*; + +import java.time.LocalDateTime; + +/** + * 设备轨迹记录 DO + *

+ * 记录工牌设备进出各区域的轨迹 + * 一条记录表示一次"进入-离开"周期 + * + * @author lzh + */ +@TableName("ops_device_trajectory") +@KeySequence("ops_device_trajectory_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class OpsDeviceTrajectoryDO extends TenantBaseDO { + + /** + * 主键 + */ + @TableId + private Long id; + + /** + * 工牌设备ID + */ + private Long deviceId; + + /** + * 设备名称(冗余) + */ + private String deviceName; + + /** + * 设备备注名称(冗余) + */ + private String nickname; + + /** + * 人员ID(预留) + */ + private Long personId; + + /** + * 人员名称(预留) + */ + private String personName; + + /** + * 区域ID + */ + private Long areaId; + + /** + * 区域名称(冗余) + */ + private String areaName; + + /** + * 匹配的 Beacon MAC + */ + private String beaconMac; + + /** + * 进入时间 + */ + private LocalDateTime enterTime; + + /** + * 离开时间 + */ + private LocalDateTime leaveTime; + + /** + * 停留时长(秒) + */ + private Integer durationSeconds; + + /** + * 离开原因 + *

+ * SIGNAL_LOSS - 信号丢失 + * AREA_SWITCH - 切换到其他区域 + * DEVICE_OFFLINE - 设备离线 + */ + private String leaveReason; + + /** + * 进入时 RSSI + */ + private Integer enterRssi; + +} diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/mysql/trajectory/OpsDeviceTrajectoryMapper.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/mysql/trajectory/OpsDeviceTrajectoryMapper.java new file mode 100644 index 00000000..b7f06ceb --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/mysql/trajectory/OpsDeviceTrajectoryMapper.java @@ -0,0 +1,83 @@ +package com.viewsh.module.ops.dal.mysql.trajectory; + +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.ops.dal.dataobject.trajectory.OpsDeviceTrajectoryDO; +import com.viewsh.module.ops.service.trajectory.dto.TrajectoryPageReqDTO; +import org.apache.ibatis.annotations.Mapper; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +/** + * 设备轨迹记录 Mapper + * + * @author lzh + */ +@Mapper +public interface OpsDeviceTrajectoryMapper extends BaseMapperX { + + /** + * 查询设备在某区域最近一条未关闭的轨迹记录 + */ + default OpsDeviceTrajectoryDO selectOpenRecord(Long deviceId, Long areaId) { + return selectOne(new LambdaQueryWrapperX() + .eq(OpsDeviceTrajectoryDO::getDeviceId, deviceId) + .eq(OpsDeviceTrajectoryDO::getAreaId, areaId) + .isNull(OpsDeviceTrajectoryDO::getLeaveTime) + .orderByDesc(OpsDeviceTrajectoryDO::getEnterTime) + .last("LIMIT 1")); + } + + /** + * 查询设备在某区域未关闭的轨迹记录(加锁,防止并发竞态) + */ + default OpsDeviceTrajectoryDO selectOpenRecordForUpdate(Long deviceId, Long areaId) { + return selectOne(new LambdaQueryWrapperX() + .eq(OpsDeviceTrajectoryDO::getDeviceId, deviceId) + .eq(OpsDeviceTrajectoryDO::getAreaId, areaId) + .isNull(OpsDeviceTrajectoryDO::getLeaveTime) + .orderByDesc(OpsDeviceTrajectoryDO::getEnterTime) + .last("LIMIT 1 FOR UPDATE")); + } + + /** + * 查询设备在某区域、按进入时间精确匹配的未关闭轨迹记录(加锁) + */ + default OpsDeviceTrajectoryDO selectOpenRecordByEnterTimeForUpdate(Long deviceId, Long areaId, + LocalDateTime enterTime) { + return selectOne(new LambdaQueryWrapperX() + .eq(OpsDeviceTrajectoryDO::getDeviceId, deviceId) + .eq(OpsDeviceTrajectoryDO::getAreaId, areaId) + .eq(OpsDeviceTrajectoryDO::getEnterTime, enterTime) + .isNull(OpsDeviceTrajectoryDO::getLeaveTime) + .last("LIMIT 1 FOR UPDATE")); + } + + /** + * 分页查询轨迹记录 + */ + default PageResult selectPage(TrajectoryPageReqDTO req) { + return selectPage(req, new LambdaQueryWrapperX() + .eqIfPresent(OpsDeviceTrajectoryDO::getDeviceId, req.getDeviceId()) + .eqIfPresent(OpsDeviceTrajectoryDO::getAreaId, req.getAreaId()) + .betweenIfPresent(OpsDeviceTrajectoryDO::getEnterTime, req.getEnterTime()) + .orderByDesc(OpsDeviceTrajectoryDO::getEnterTime)); + } + + /** + * 查询某设备某天的轨迹时间线(不分页,按进入时间升序) + */ + default List selectTimeline(Long deviceId, LocalDate date) { + LocalDateTime start = date.atStartOfDay(); + LocalDateTime end = date.plusDays(1).atStartOfDay(); + return selectList(new LambdaQueryWrapperX() + .eq(OpsDeviceTrajectoryDO::getDeviceId, deviceId) + .ge(OpsDeviceTrajectoryDO::getEnterTime, start) + .lt(OpsDeviceTrajectoryDO::getEnterTime, end) + .orderByAsc(OpsDeviceTrajectoryDO::getEnterTime)); + } + +} From d3eecc63effa4f1f7e3e6938a07ff14fee04ef92 Mon Sep 17 00:00:00 2001 From: lzh Date: Tue, 31 Mar 2026 22:56:49 +0800 Subject: [PATCH 03/16] =?UTF-8?q?feat(trajectory):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E8=BD=A8=E8=BF=B9=E5=90=8E=E5=8F=B0=E6=9F=A5=E8=AF=A2=E4=B8=8E?= =?UTF-8?q?=E5=AE=9E=E6=97=B6=E4=BD=8D=E7=BD=AE=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增轨迹分页、时间线、统计摘要等查询 DTO\n- 提供轨迹后台控制器,支持工牌下拉、轨迹查询、实时位置查询\n- 接入 TrajectoryStateApi 的 Feign 配置,打通 Ops 对 IoT 实时位置状态的读取 --- .../trajectory/dto/BadgeSimpleRespDTO.java | 30 +++ .../dto/DeviceCurrentLocationDTO.java | 39 ++++ .../trajectory/dto/TrajectoryPageReqDTO.java | 35 ++++ .../trajectory/dto/TrajectoryRespDTO.java | 59 ++++++ .../trajectory/dto/TrajectorySummaryDTO.java | 41 ++++ .../dto/TrajectoryTimelineReqDTO.java | 28 +++ .../trajectory/TrajectoryController.java | 181 ++++++++++++++++++ .../rpc/config/RpcConfiguration.java | 2 + 8 files changed, 415 insertions(+) create mode 100644 viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/trajectory/dto/BadgeSimpleRespDTO.java create mode 100644 viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/trajectory/dto/DeviceCurrentLocationDTO.java create mode 100644 viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/trajectory/dto/TrajectoryPageReqDTO.java create mode 100644 viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/trajectory/dto/TrajectoryRespDTO.java create mode 100644 viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/trajectory/dto/TrajectorySummaryDTO.java create mode 100644 viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/trajectory/dto/TrajectoryTimelineReqDTO.java create mode 100644 viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/trajectory/TrajectoryController.java diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/trajectory/dto/BadgeSimpleRespDTO.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/trajectory/dto/BadgeSimpleRespDTO.java new file mode 100644 index 00000000..cc3be191 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/trajectory/dto/BadgeSimpleRespDTO.java @@ -0,0 +1,30 @@ +package com.viewsh.module.ops.service.trajectory.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 工牌设备精简 Response DTO(下拉列表用) + * + * @author lzh + */ +@Schema(description = "管理后台 - 工牌设备精简信息(下拉列表用)") +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class BadgeSimpleRespDTO { + + @Schema(description = "设备ID", example = "31") + private Long deviceId; + + @Schema(description = "设备Key", example = "09207455611") + private String deviceKey; + + @Schema(description = "设备备注名称", example = "1号工牌") + private String nickname; + +} diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/trajectory/dto/DeviceCurrentLocationDTO.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/trajectory/dto/DeviceCurrentLocationDTO.java new file mode 100644 index 00000000..9881f6b1 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/trajectory/dto/DeviceCurrentLocationDTO.java @@ -0,0 +1,39 @@ +package com.viewsh.module.ops.service.trajectory.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 设备实时位置 Response DTO + * + * @author lzh + */ +@Schema(description = "管理后台 - 设备实时位置 Response DTO") +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class DeviceCurrentLocationDTO { + + @Schema(description = "设备ID", example = "31") + private Long deviceId; + + @Schema(description = "当前所在区域ID", example = "1301") + private Long areaId; + + @Schema(description = "区域名称", example = "A座2楼男卫") + private String areaName; + + @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-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/trajectory/dto/TrajectoryPageReqDTO.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/trajectory/dto/TrajectoryPageReqDTO.java new file mode 100644 index 00000000..14e7758f --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/trajectory/dto/TrajectoryPageReqDTO.java @@ -0,0 +1,35 @@ +package com.viewsh.module.ops.service.trajectory.dto; + +import com.viewsh.framework.common.pojo.PageParam; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Size; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDateTime; + +import static com.viewsh.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; + +/** + * 轨迹分页查询 Request DTO + * + * @author lzh + */ +@Schema(description = "管理后台 - 设备轨迹分页查询 Request DTO") +@Data +@EqualsAndHashCode(callSuper = true) +public class TrajectoryPageReqDTO extends PageParam { + + @Schema(description = "设备ID", example = "31") + private Long deviceId; + + @Schema(description = "区域ID", example = "1301") + private Long areaId; + + @Schema(description = "进入时间范围(开始时间, 结束时间)") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + @Size(min = 2, max = 2, message = "进入时间范围必须包含开始时间和结束时间") + private LocalDateTime[] enterTime; + +} diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/trajectory/dto/TrajectoryRespDTO.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/trajectory/dto/TrajectoryRespDTO.java new file mode 100644 index 00000000..07b4847a --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/trajectory/dto/TrajectoryRespDTO.java @@ -0,0 +1,59 @@ +package com.viewsh.module.ops.service.trajectory.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 轨迹记录 Response DTO + * + * @author lzh + */ +@Schema(description = "管理后台 - 设备轨迹记录 Response DTO") +@Data +public class TrajectoryRespDTO { + + @Schema(description = "记录ID", example = "1") + private Long id; + + @Schema(description = "设备ID", example = "31") + private Long deviceId; + + @Schema(description = "设备名称", example = "badge_001") + private String deviceName; + + @Schema(description = "设备备注名称", example = "1号工牌") + private String nickname; + + @Schema(description = "区域ID", example = "1301") + private Long areaId; + + @Schema(description = "区域名称", example = "A座2楼男卫") + private String areaName; + + @Schema(description = "楼栋名称", example = "A栋") + private String buildingName; + + @Schema(description = "楼层号", example = "2") + private Integer floorNo; + + @Schema(description = "Beacon MAC", example = "F0:C8:60:1D:10:BB") + private String beaconMac; + + @Schema(description = "进入时间") + private LocalDateTime enterTime; + + @Schema(description = "离开时间") + private LocalDateTime leaveTime; + + @Schema(description = "停留时长(秒)", example = "300") + private Integer durationSeconds; + + @Schema(description = "离开原因", example = "SIGNAL_LOSS") + private String leaveReason; + + @Schema(description = "进入时RSSI", example = "-65") + private Integer enterRssi; + +} diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/trajectory/dto/TrajectorySummaryDTO.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/trajectory/dto/TrajectorySummaryDTO.java new file mode 100644 index 00000000..1f651fc8 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/trajectory/dto/TrajectorySummaryDTO.java @@ -0,0 +1,41 @@ +package com.viewsh.module.ops.service.trajectory.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 轨迹统计摘要 Response DTO + *

+ * 用于 KPI 卡片展示 + * + * @author lzh + */ +@Schema(description = "管理后台 - 轨迹统计摘要 Response DTO") +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TrajectorySummaryDTO { + + @Schema(description = "总轨迹记录数(含未关闭)", example = "42") + private Long totalRecords; + + @Schema(description = "已完成记录数(有离开时间)", example = "38") + private Long completedRecords; + + @Schema(description = "覆盖区域数", example = "8") + private Long coveredAreaCount; + + @Schema(description = "总停留时长(秒)", example = "28800") + private Long totalDurationSeconds; + + @Schema(description = "平均停留时长(秒)", example = "685") + private Long avgDurationSeconds; + + @Schema(description = "最长单次停留(秒)", example = "3600") + private Long maxDurationSeconds; + +} diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/trajectory/dto/TrajectoryTimelineReqDTO.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/trajectory/dto/TrajectoryTimelineReqDTO.java new file mode 100644 index 00000000..d76acad4 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/trajectory/dto/TrajectoryTimelineReqDTO.java @@ -0,0 +1,28 @@ +package com.viewsh.module.ops.service.trajectory.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDate; + +/** + * 轨迹时间线查询 Request DTO + * + * @author lzh + */ +@Schema(description = "管理后台 - 设备轨迹时间线查询 Request DTO") +@Data +public class TrajectoryTimelineReqDTO { + + @Schema(description = "设备ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "31") + @NotNull(message = "设备ID不能为空") + private Long deviceId; + + @Schema(description = "查询日期", requiredMode = Schema.RequiredMode.REQUIRED, example = "2026-03-30") + @NotNull(message = "查询日期不能为空") + @DateTimeFormat(pattern = "yyyy-MM-dd") + private LocalDate date; + +} diff --git a/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/trajectory/TrajectoryController.java b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/trajectory/TrajectoryController.java new file mode 100644 index 00000000..28f15e10 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/trajectory/TrajectoryController.java @@ -0,0 +1,181 @@ +package com.viewsh.module.ops.controller.admin.trajectory; + +import com.viewsh.framework.common.pojo.CommonResult; +import com.viewsh.framework.common.pojo.PageResult; +import com.viewsh.module.iot.api.device.IotDeviceQueryApi; +import com.viewsh.module.iot.api.device.dto.IotDeviceSimpleRespDTO; +import com.viewsh.module.iot.api.trajectory.DeviceLocationDTO; +import com.viewsh.module.iot.api.trajectory.TrajectoryStateApi; +import com.viewsh.module.ops.dal.dataobject.area.OpsAreaDeviceRelationDO; +import com.viewsh.module.ops.dal.dataobject.vo.area.OpsBusAreaRespVO; +import com.viewsh.module.ops.environment.service.trajectory.DeviceTrajectoryService; +import com.viewsh.module.ops.service.area.AreaDeviceService; +import com.viewsh.module.ops.service.area.OpsBusAreaService; +import com.viewsh.module.ops.service.trajectory.dto.*; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.*; +import java.util.function.Function; +import java.util.stream.Collectors; + +import static com.viewsh.framework.common.pojo.CommonResult.success; + +/** + * 管理后台 - 设备轨迹 + * + * @author lzh + */ +@Tag(name = "管理后台 - 设备轨迹") +@Slf4j +@RestController +@RequestMapping("/ops/trajectory") +@Validated +public class TrajectoryController { + + @Resource + private DeviceTrajectoryService trajectoryService; + + @Resource + private TrajectoryStateApi trajectoryStateApi; + + @Resource + private AreaDeviceService areaDeviceService; + + @Resource + private OpsBusAreaService opsBusAreaService; + + @Resource + private IotDeviceQueryApi iotDeviceQueryApi; + + // ==================== 工牌设备下拉列表 ==================== + + @GetMapping("/badge-list") + @Operation(summary = "获取工牌设备下拉列表(轨迹页面用)") + @PreAuthorize("@ss.hasPermission('ops:trajectory:query')") + public CommonResult> getBadgeSimpleList() { + // 查询所有 relationType=BADGE 的设备关联 + List relations = areaDeviceService.listAllByType("BADGE"); + + // 按 deviceId 去重(同一设备可能绑定多个区域) + Map uniqueDevices = relations.stream() + .collect(Collectors.toMap( + OpsAreaDeviceRelationDO::getDeviceId, + Function.identity(), + (a, b) -> a, + LinkedHashMap::new)); + + // 批量查询设备信息以获取 nickname + Set deviceIds = uniqueDevices.keySet(); + Map deviceMap = Collections.emptyMap(); + if (!deviceIds.isEmpty()) { + try { + CommonResult> deviceResult = + iotDeviceQueryApi.batchGetDevices(deviceIds); + if (deviceResult != null && deviceResult.getData() != null) { + deviceMap = deviceResult.getData().stream() + .collect(Collectors.toMap(IotDeviceSimpleRespDTO::getId, Function.identity())); + } + } catch (Exception e) { + log.warn("[badge-list] 批量查询设备信息失败,降级返回无 nickname", e); + } + } + + Map finalDeviceMap = deviceMap; + List result = uniqueDevices.values().stream() + .map(r -> { + BadgeSimpleRespDTO dto = BadgeSimpleRespDTO.builder() + .deviceId(r.getDeviceId()) + .deviceKey(r.getDeviceKey()) + .build(); + IotDeviceSimpleRespDTO device = finalDeviceMap.get(r.getDeviceId()); + if (device != null) { + dto.setNickname(device.getNickname()); + } + return dto; + }) + .collect(Collectors.toList()); + return success(result); + } + + // ==================== 轨迹查询 ==================== + + @GetMapping("/page") + @Operation(summary = "获得设备轨迹分页") + @PreAuthorize("@ss.hasPermission('ops:trajectory:query')") + public CommonResult> getTrajectoryPage( + @Valid TrajectoryPageReqDTO pageReq) { + return success(trajectoryService.getTrajectoryPage(pageReq)); + } + + @GetMapping("/timeline") + @Operation(summary = "获得设备某天的轨迹时间线") + @PreAuthorize("@ss.hasPermission('ops:trajectory:query')") + public CommonResult> getTimeline(@Valid TrajectoryTimelineReqDTO req) { + return success(trajectoryService.getTimeline(req.getDeviceId(), req.getDate())); + } + + @GetMapping("/summary") + @Operation(summary = "获得设备某天的轨迹统计摘要") + @PreAuthorize("@ss.hasPermission('ops:trajectory:query')") + public CommonResult getSummary(@Valid TrajectoryTimelineReqDTO req) { + return success(trajectoryService.getSummary(req.getDeviceId(), req.getDate())); + } + + // ==================== 实时位置 ==================== + + @GetMapping("/current-location") + @Operation(summary = "获得设备当前位置(实时)") + @PreAuthorize("@ss.hasPermission('ops:trajectory:query')") + public CommonResult getCurrentLocation( + @Parameter(description = "设备ID", required = true, example = "31") + @RequestParam("deviceId") Long deviceId) { + // 调用 IoT RPC 查询设备实时位置 + CommonResult result; + try { + result = trajectoryStateApi.getCurrentLocation(deviceId); + } catch (Exception e) { + log.error("[current-location] IoT 服务调用失败:deviceId={}", deviceId, e); + throw new RuntimeException("IoT 服务不可用,无法查询设备实时位置", e); + } + + if (result == null || !result.isSuccess() || result.getData() == null + || !Boolean.TRUE.equals(result.getData().getInArea())) { + return success(DeviceCurrentLocationDTO.builder() + .deviceId(deviceId) + .inArea(false) + .build()); + } + + DeviceLocationDTO location = result.getData(); + // 通过 Service 查询区域名称(不直接注入 Mapper) + String areaName = null; + if (location.getAreaId() != null) { + try { + OpsBusAreaRespVO area = opsBusAreaService.getArea(location.getAreaId()); + areaName = area.getAreaName(); + } catch (Exception e) { + log.warn("[current-location] 查询区域名称失败:areaId={}", location.getAreaId(), e); + } + } + + return success(DeviceCurrentLocationDTO.builder() + .deviceId(location.getDeviceId()) + .areaId(location.getAreaId()) + .areaName(areaName) + .enterTime(location.getEnterTime()) + .beaconMac(location.getBeaconMac()) + .inArea(true) + .build()); + } +} diff --git a/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/framework/rpc/config/RpcConfiguration.java b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/framework/rpc/config/RpcConfiguration.java index da664359..d9a1f7bc 100644 --- a/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/framework/rpc/config/RpcConfiguration.java +++ b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/framework/rpc/config/RpcConfiguration.java @@ -4,6 +4,7 @@ import com.viewsh.module.infra.api.file.FileApi; import com.viewsh.module.iot.api.device.IotDeviceControlApi; import com.viewsh.module.iot.api.device.IotDeviceQueryApi; import com.viewsh.module.iot.api.device.IotDeviceStatusQueryApi; +import com.viewsh.module.iot.api.trajectory.TrajectoryStateApi; import com.viewsh.module.system.api.notify.NotifyMessageSendApi; import com.viewsh.module.system.api.social.SocialUserApi; import com.viewsh.module.system.api.user.AdminUserApi; @@ -18,6 +19,7 @@ import org.springframework.context.annotation.Configuration; IotDeviceControlApi.class, IotDeviceQueryApi.class, IotDeviceStatusQueryApi.class, + TrajectoryStateApi.class, FileApi.class }) public class RpcConfiguration { From f0fa5f1c4629690be2a58fdc06da2d4982e3650b Mon Sep 17 00:00:00 2001 From: lzh Date: Tue, 31 Mar 2026 22:57:28 +0800 Subject: [PATCH 04/16] =?UTF-8?q?fix(clean):=20=E8=A1=A5=E9=BD=90=E5=AE=A2?= =?UTF-8?q?=E6=B5=81=E6=B4=BB=E8=B7=83=E5=B7=A5=E5=8D=95=E7=BC=93=E5=AD=98?= =?UTF-8?q?=E8=87=AA=E6=84=88=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 为客流活跃工单 Redis 标记补充 TTL,避免长期残留\n- 创建工单前命中 Redis 时回查 DB,自动清理终态脏数据并刷新过期状态\n- 新增启动校准器,服务启动时批量清理或刷新 area 级活跃工单缓存 --- .../dal/redis/TrafficActiveOrderRedisDAO.java | 5 + .../CleanOrderCreateEventHandler.java | 28 +++++ .../job/TrafficActiveOrderInitializer.java | 113 ++++++++++++++++++ 3 files changed, 146 insertions(+) create mode 100644 viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/job/TrafficActiveOrderInitializer.java diff --git a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/dal/redis/TrafficActiveOrderRedisDAO.java b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/dal/redis/TrafficActiveOrderRedisDAO.java index 3b473e27..85a366e0 100644 --- a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/dal/redis/TrafficActiveOrderRedisDAO.java +++ b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/dal/redis/TrafficActiveOrderRedisDAO.java @@ -5,6 +5,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Repository; +import java.time.Duration; import java.util.Map; /** @@ -23,6 +24,7 @@ import java.util.Map; public class TrafficActiveOrderRedisDAO { private static final String KEY_PATTERN = "ops:clean:traffic:active-order:%s"; + private static final Duration ACTIVE_ORDER_TTL = Duration.ofHours(24); private static final String FIELD_ORDER_ID = "orderId"; private static final String FIELD_STATUS = "status"; @@ -41,6 +43,7 @@ public class TrafficActiveOrderRedisDAO { FIELD_STATUS, status, FIELD_PRIORITY, String.valueOf(priority) )); + stringRedisTemplate.expire(key, ACTIVE_ORDER_TTL); log.debug("[TrafficActiveOrderRedisDAO] 标记活跃工单: areaId={}, orderId={}, status={}, priority={}", areaId, orderId, status, priority); } @@ -76,6 +79,7 @@ public class TrafficActiveOrderRedisDAO { String key = buildKey(areaId); if (Boolean.TRUE.equals(stringRedisTemplate.hasKey(key))) { stringRedisTemplate.opsForHash().put(key, FIELD_STATUS, newStatus); + stringRedisTemplate.expire(key, ACTIVE_ORDER_TTL); log.debug("[TrafficActiveOrderRedisDAO] 更新状态: areaId={}, newStatus={}", areaId, newStatus); } } @@ -87,6 +91,7 @@ public class TrafficActiveOrderRedisDAO { String key = buildKey(areaId); if (Boolean.TRUE.equals(stringRedisTemplate.hasKey(key))) { stringRedisTemplate.opsForHash().put(key, FIELD_PRIORITY, String.valueOf(newPriority)); + stringRedisTemplate.expire(key, ACTIVE_ORDER_TTL); log.debug("[TrafficActiveOrderRedisDAO] 更新优先级: areaId={}, newPriority={}", areaId, newPriority); } } diff --git a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/integration/consumer/CleanOrderCreateEventHandler.java b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/integration/consumer/CleanOrderCreateEventHandler.java index 723860c8..8944d33f 100644 --- a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/integration/consumer/CleanOrderCreateEventHandler.java +++ b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/integration/consumer/CleanOrderCreateEventHandler.java @@ -160,6 +160,34 @@ public class CleanOrderCreateEventHandler implements RocketMQListener { } } + // 1.5 Redis 命中时,回查 DB 校验真实状态(防止缓存与 DB 不一致) + if (activeOrder != null) { + OpsOrderDO dbOrder = opsOrderMapper.selectById(activeOrder.getOrderId()); + if (dbOrder == null + || WorkOrderStatusEnum.COMPLETED.getStatus().equals(dbOrder.getStatus()) + || WorkOrderStatusEnum.CANCELLED.getStatus().equals(dbOrder.getStatus())) { + // 工单已终态或已删除,Redis 标记过期,清除 + log.warn("[CleanOrderCreateEventHandler] Redis活跃标记已过期(DB状态:{}),清除: areaId={}, orderId={}", + dbOrder != null ? dbOrder.getStatus() : "NOT_FOUND", areaId, activeOrder.getOrderId()); + trafficActiveOrderRedisDAO.removeActive(areaId); + activeOrder = null; + } else if (!activeOrder.getStatus().equals(dbOrder.getStatus()) + || !activeOrder.getPriority().equals(dbOrder.getPriority())) { + // Redis 状态/优先级过期,用 DB 真实值刷新 + log.info("[CleanOrderCreateEventHandler] Redis状态过期已刷新: areaId={}, orderId={}, redis={}/{}, db={}/{}", + areaId, activeOrder.getOrderId(), + activeOrder.getStatus(), activeOrder.getPriority(), + dbOrder.getStatus(), dbOrder.getPriority()); + activeOrder = ActiveOrderInfo.builder() + .orderId(dbOrder.getId()) + .status(dbOrder.getStatus()) + .priority(dbOrder.getPriority()) + .build(); + trafficActiveOrderRedisDAO.markActive(areaId, dbOrder.getId(), + dbOrder.getStatus(), dbOrder.getPriority()); + } + } + // 2. 有活跃工单 → 升级或忽略 if (activeOrder != null) { String status = activeOrder.getStatus(); diff --git a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/job/TrafficActiveOrderInitializer.java b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/job/TrafficActiveOrderInitializer.java new file mode 100644 index 00000000..40e77c90 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/job/TrafficActiveOrderInitializer.java @@ -0,0 +1,113 @@ +package com.viewsh.module.ops.environment.job; + +import com.viewsh.framework.tenant.core.util.TenantUtils; +import com.viewsh.module.ops.dal.dataobject.workorder.OpsOrderDO; +import com.viewsh.module.ops.dal.mysql.workorder.OpsOrderMapper; +import com.viewsh.module.ops.environment.dal.redis.TrafficActiveOrderRedisDAO; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; + +import java.util.Map; +import java.util.Set; + +/** + * 客流活跃工单 Redis 启动校准器 + *

+ * 职责:服务启动时,扫描所有 ops:clean:traffic:active-order:* key, + * 逐个与 DB 比对,清理已终态(COMPLETED/CANCELLED)或已删除的残留标记。 + *

+ * 解决场景: + * - 服务重启期间 Spring Event 丢失,导致 removeActive 未执行 + * - 异常导致 clearTrafficActiveOrder 被跳过 + * + * @author AI + */ +@Slf4j +@Component +public class TrafficActiveOrderInitializer implements ApplicationRunner { + + private static final String KEY_PREFIX = "ops:clean:traffic:active-order:"; + + @Resource + private StringRedisTemplate stringRedisTemplate; + + @Resource + private OpsOrderMapper opsOrderMapper; + + @Resource + private TrafficActiveOrderRedisDAO trafficActiveOrderRedisDAO; + + @Override + public void run(ApplicationArguments args) { + log.info("[初始化] 开始校准客流活跃工单 Redis 标记..."); + + try { + TenantUtils.executeIgnore(this::calibrate); + } catch (Exception e) { + log.error("[初始化] 客流活跃工单 Redis 校准失败", e); + } + } + + private void calibrate() { + // SCAN 匹配所有活跃工单 key + Set keys = stringRedisTemplate.keys(KEY_PREFIX + "*"); + if (keys == null || keys.isEmpty()) { + log.info("[初始化] 无客流活跃工单 Redis 标记,跳过校准"); + return; + } + + int total = keys.size(); + int cleaned = 0; + int refreshed = 0; + + for (String key : keys) { + try { + Map entries = stringRedisTemplate.opsForHash().entries(key); + String orderIdStr = (String) entries.get("orderId"); + if (orderIdStr == null) { + stringRedisTemplate.delete(key); + cleaned++; + continue; + } + + Long orderId = Long.parseLong(orderIdStr); + OpsOrderDO dbOrder = opsOrderMapper.selectById(orderId); + + // 提取 areaId from key: ops:clean:traffic:active-order:{areaId} + String areaIdStr = key.substring(KEY_PREFIX.length()); + Long areaId = Long.parseLong(areaIdStr); + + if (dbOrder == null + || "COMPLETED".equals(dbOrder.getStatus()) + || "CANCELLED".equals(dbOrder.getStatus())) { + // 工单已终态或不存在,清除残留标记 + trafficActiveOrderRedisDAO.removeActive(areaId); + log.info("[初始化] 清除过期活跃标记: areaId={}, orderId={}, dbStatus={}", + areaId, orderId, dbOrder != null ? dbOrder.getStatus() : "NOT_FOUND"); + cleaned++; + } else { + // 工单仍活跃,用 DB 真实状态刷新 Redis + String cachedStatus = (String) entries.get("status"); + String cachedPriority = (String) entries.get("priority"); + if (!dbOrder.getStatus().equals(cachedStatus) + || !String.valueOf(dbOrder.getPriority()).equals(cachedPriority)) { + trafficActiveOrderRedisDAO.markActive(areaId, dbOrder.getId(), + dbOrder.getStatus(), dbOrder.getPriority()); + log.info("[初始化] 刷新活跃标记状态: areaId={}, orderId={}, {}→{}", + areaId, orderId, cachedStatus, dbOrder.getStatus()); + refreshed++; + } + } + } catch (Exception e) { + log.warn("[初始化] 校准单个 key 失败: key={}", key, e); + } + } + + log.info("[初始化] 客流活跃工单 Redis 校准完成:总计 {} 个,清除 {} 个,刷新 {} 个", + total, cleaned, refreshed); + } +} From 1696aeb28735fe50a64c5c3f33651ab728bf9cc0 Mon Sep 17 00:00:00 2001 From: lzh Date: Tue, 31 Mar 2026 22:57:44 +0800 Subject: [PATCH 05/16] =?UTF-8?q?fix(clean):=20=E5=8F=96=E6=B6=88=E5=B7=A5?= =?UTF-8?q?=E5=8D=95=E5=89=8D=E5=85=88=E6=B8=85=E7=90=86=E5=AE=A2=E6=B5=81?= =?UTF-8?q?=E6=B4=BB=E8=B7=83=E6=A0=87=E8=AE=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 调整 CANCELLED 事件处理顺序\n- 先移除 area 级活跃工单 Redis 标记,再执行后续取消逻辑\n- 避免后续取消处理异常时遗留错误的活跃状态 --- .../integration/listener/CleanOrderEventListener.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/integration/listener/CleanOrderEventListener.java b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/integration/listener/CleanOrderEventListener.java index b0a8e1ca..c4358d40 100644 --- a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/integration/listener/CleanOrderEventListener.java +++ b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/integration/listener/CleanOrderEventListener.java @@ -194,9 +194,10 @@ public class CleanOrderEventListener { clearTrafficActiveOrderOnComplete(event); break; case CANCELLED: - - handleCancelled(event); + // ★ 先清 Redis 活跃标记,再处理取消逻辑 + // 确保即使 handleCancelled 异常,Redis 标记也能被清除 clearTrafficActiveOrder(event); + handleCancelled(event); break; case QUEUED: handleQueued(event); From 306303ab16fb053387bbf05a09a28299886c1ff3 Mon Sep 17 00:00:00 2001 From: lzh Date: Tue, 31 Mar 2026 22:58:09 +0800 Subject: [PATCH 06/16] =?UTF-8?q?fix(ops):=20=E5=90=AF=E5=8A=A8=E6=97=B6?= =?UTF-8?q?=E6=A0=A1=E5=87=86=E4=BA=BA=E5=91=98=E8=B0=83=E5=BA=A6=E7=8A=B6?= =?UTF-8?q?=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 为 UserDispatchStatusService 增加基于 DB 的重建能力\n- 扫描 Redis 中的人员调度 key,按实际活跃工单数修正 status、activeOrderCount、waitingTaskCount\n- 新增启动初始化器,服务启动时自动执行一次校准,缓解事件丢失导致的 BUSY 残留 --- .../dispatch/UserDispatchStatusService.java | 10 ++ .../UserDispatchStatusServiceImpl.java | 108 ++++++++++++++++++ .../UserDispatchStatusInitializer.java | 43 +++++++ 3 files changed, 161 insertions(+) create mode 100644 viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/framework/job/dispatch/UserDispatchStatusInitializer.java diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/dispatch/UserDispatchStatusService.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/dispatch/UserDispatchStatusService.java index bd3059f1..30ede0f0 100644 --- a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/dispatch/UserDispatchStatusService.java +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/dispatch/UserDispatchStatusService.java @@ -151,4 +151,14 @@ public interface UserDispatchStatusService { * @return 活跃工单数,无记录返回 0 */ int getActiveOrderCount(Long userId); + + /** + * 启动校准:从 DB 重建人员调度状态 + *

+ * 扫描 Redis 中所有 ops:user:dispatch:* key,校验 currentOrderId 对应的工单是否仍在进行中, + * 并根据 DB 中实际活跃工单数修正 activeOrderCount / waitingTaskCount / status。 + * + * @return 校准的用户数 + */ + int calibrateFromDb(); } diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/dispatch/UserDispatchStatusServiceImpl.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/dispatch/UserDispatchStatusServiceImpl.java index 4529cfbb..9546a49e 100644 --- a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/dispatch/UserDispatchStatusServiceImpl.java +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/dispatch/UserDispatchStatusServiceImpl.java @@ -1,6 +1,9 @@ package com.viewsh.module.ops.service.dispatch; import com.viewsh.module.ops.api.dispatch.UserDispatchStatusDTO; +import com.viewsh.module.ops.dal.dataobject.workorder.OpsOrderDO; +import com.viewsh.module.ops.dal.mysql.workorder.OpsOrderMapper; +import com.viewsh.module.ops.enums.WorkOrderStatusEnum; import jakarta.annotation.PostConstruct; import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; @@ -9,6 +12,7 @@ import org.springframework.data.redis.core.script.DefaultRedisScript; import org.springframework.stereotype.Service; import java.util.*; +import java.util.stream.Collectors; /** * 通用人员调度状态服务 - Redis 实现 @@ -31,6 +35,9 @@ public class UserDispatchStatusServiceImpl implements UserDispatchStatusService @Resource private StringRedisTemplate stringRedisTemplate; + @Resource + private OpsOrderMapper opsOrderMapper; + // ==================== Lua 脚本 ==================== private DefaultRedisScript dispatchScript; @@ -297,6 +304,107 @@ public class UserDispatchStatusServiceImpl implements UserDispatchStatusService } } + // ==================== 启动校准 ==================== + + @Override + public int calibrateFromDb() { + // 1. SCAN 所有 user dispatch key + Set keys = stringRedisTemplate.keys(KEY_PREFIX + "*"); + if (keys == null || keys.isEmpty()) { + return 0; + } + + int calibrated = 0; + + for (String key : keys) { + try { + String userIdStr = key.substring(KEY_PREFIX.length()); + Long userId = Long.parseLong(userIdStr); + + // 2. 查 DB:该用户所有非终态、非保洁的活跃工单 + List activeOrders = opsOrderMapper.selectList( + new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() + .eq(OpsOrderDO::getAssigneeId, userId) + .ne(OpsOrderDO::getOrderType, "CLEAN") + .notIn(OpsOrderDO::getStatus, + WorkOrderStatusEnum.COMPLETED.getStatus(), + WorkOrderStatusEnum.CANCELLED.getStatus()) + ); + + if (activeOrders.isEmpty()) { + // 无活跃工单,但 Redis 中有记录 → 应该是 IDLE + Map current = stringRedisTemplate.opsForHash().entries(key); + String currentStatus = current.get("status") != null ? current.get("status").toString() : null; + if (!"IDLE".equals(currentStatus) || !"0".equals(Objects.toString(current.get("activeOrderCount"), "0"))) { + // 重置为 IDLE + stringRedisTemplate.opsForHash().putAll(key, Map.of( + "status", "IDLE", + "activeOrderCount", "0", + "waitingTaskCount", "0", + "lastUpdateTime", String.valueOf(System.currentTimeMillis()) + )); + stringRedisTemplate.opsForHash().delete(key, "currentOrderId", "currentOrderType", "currentOrderStatus"); + log.info("[校准] 人员状态重置为IDLE: userId={}, 原状态={}", userId, currentStatus); + calibrated++; + } + } else { + // 有活跃工单 → 重建计数 + int activeCount = activeOrders.size(); + int waitingCount = (int) activeOrders.stream() + .filter(o -> WorkOrderStatusEnum.QUEUED.getStatus().equals(o.getStatus())) + .count(); + + // 找当前正在执行的工单(DISPATCHED/CONFIRMED/ARRIVED 中最新的) + OpsOrderDO currentOrder = activeOrders.stream() + .filter(o -> { + String s = o.getStatus(); + return WorkOrderStatusEnum.DISPATCHED.getStatus().equals(s) + || WorkOrderStatusEnum.CONFIRMED.getStatus().equals(s) + || WorkOrderStatusEnum.ARRIVED.getStatus().equals(s); + }) + .max(Comparator.comparing(OpsOrderDO::getUpdateTime)) + .orElse(null); + + // 判断是否有暂停的工单 + boolean hasPaused = activeOrders.stream() + .anyMatch(o -> WorkOrderStatusEnum.PAUSED.getStatus().equals(o.getStatus())); + + String status = hasPaused ? "PAUSED" : "BUSY"; + + Map newHash = new HashMap<>(); + newHash.put("status", status); + newHash.put("activeOrderCount", String.valueOf(activeCount)); + newHash.put("waitingTaskCount", String.valueOf(waitingCount)); + newHash.put("lastUpdateTime", String.valueOf(System.currentTimeMillis())); + if (currentOrder != null) { + newHash.put("currentOrderId", String.valueOf(currentOrder.getId())); + newHash.put("currentOrderType", currentOrder.getOrderType()); + newHash.put("currentOrderStatus", currentOrder.getStatus()); + } + + // 比对 Redis 当前值,有差异则修正 + Map current = stringRedisTemplate.opsForHash().entries(key); + boolean needUpdate = !String.valueOf(activeCount).equals(Objects.toString(current.get("activeOrderCount"), "")) + || !String.valueOf(waitingCount).equals(Objects.toString(current.get("waitingTaskCount"), "")) + || !status.equals(Objects.toString(current.get("status"), "")); + + if (needUpdate) { + stringRedisTemplate.delete(key); + stringRedisTemplate.opsForHash().putAll(key, newHash); + stringRedisTemplate.expire(key, java.time.Duration.ofSeconds(TTL_SECONDS)); + log.info("[校准] 人员状态已修正: userId={}, status={}, active={}, waiting={}", + userId, status, activeCount, waitingCount); + calibrated++; + } + } + } catch (Exception e) { + log.warn("[校准] 人员状态校准失败: key={}", key, e); + } + } + + return calibrated; + } + // ==================== Lua 脚本定义 ==================== /** diff --git a/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/framework/job/dispatch/UserDispatchStatusInitializer.java b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/framework/job/dispatch/UserDispatchStatusInitializer.java new file mode 100644 index 00000000..5db37d43 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/framework/job/dispatch/UserDispatchStatusInitializer.java @@ -0,0 +1,43 @@ +package com.viewsh.module.ops.framework.job.dispatch; + +import com.viewsh.framework.tenant.core.util.TenantUtils; +import com.viewsh.module.ops.service.dispatch.UserDispatchStatusService; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.stereotype.Component; + +/** + * 人员调度状态 Redis 启动校准器 + *

+ * 职责:服务启动时,扫描 Redis 中所有人员调度状态 key, + * 与 DB 中的实际活跃工单比对,修正 status / activeOrderCount / waitingTaskCount。 + *

+ * 解决场景: + * - 服务重启期间工单完成/取消事件丢失,导致人员状态卡在 BUSY + * - 计数器漂移(increment/decrement 不对称) + * + * @author AI + */ +@Slf4j +@Component +public class UserDispatchStatusInitializer implements ApplicationRunner { + + @Resource + private UserDispatchStatusService userDispatchStatusService; + + @Override + public void run(ApplicationArguments args) { + log.info("[初始化] 开始校准人员调度状态..."); + + try { + TenantUtils.executeIgnore(() -> { + int calibrated = userDispatchStatusService.calibrateFromDb(); + log.info("[初始化] 人员调度状态校准完成:修正 {} 个用户", calibrated); + }); + } catch (Exception e) { + log.error("[初始化] 人员调度状态校准失败", e); + } + } +} From 5d46502fb9781088e748c61c9729f6bd9c88fab2 Mon Sep 17 00:00:00 2001 From: lzh Date: Tue, 31 Mar 2026 22:58:25 +0800 Subject: [PATCH 07/16] =?UTF-8?q?fix(ops):=20=E5=90=AF=E5=8A=A8=E6=97=B6?= =?UTF-8?q?=E6=81=A2=E5=A4=8D=E5=B7=A5=E5=8D=95=E9=98=9F=E5=88=97=E7=BC=93?= =?UTF-8?q?=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 OrderQueueInitializer\n- 服务启动时调用 QueueSyncService.forceSyncAll()\n- 在 Redis 队列数据丢失或过期后,自动用 MySQL 数据回填 Sorted Set --- .../job/queue/OrderQueueInitializer.java | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/framework/job/queue/OrderQueueInitializer.java diff --git a/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/framework/job/queue/OrderQueueInitializer.java b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/framework/job/queue/OrderQueueInitializer.java new file mode 100644 index 00000000..ebf86234 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/framework/job/queue/OrderQueueInitializer.java @@ -0,0 +1,43 @@ +package com.viewsh.module.ops.framework.job.queue; + +import com.viewsh.framework.tenant.core.util.TenantUtils; +import com.viewsh.module.ops.service.queue.QueueSyncService; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.stereotype.Component; + +/** + * 工单队列 Redis 启动恢复器 + *

+ * 职责:服务启动时,全量同步 MySQL 队列数据到 Redis, + * 确保 Redis Sorted Set 与 MySQL 一致。 + *

+ * 解决场景: + * - 服务重启后 Redis 队列数据丢失或过期 + * - 异步写入 Redis 失败导致的数据不一致 + * + * @author AI + */ +@Slf4j +@Component +public class OrderQueueInitializer implements ApplicationRunner { + + @Resource + private QueueSyncService queueSyncService; + + @Override + public void run(ApplicationArguments args) { + log.info("[初始化] 开始全量同步工单队列到 Redis..."); + + try { + TenantUtils.executeIgnore(() -> { + int count = queueSyncService.forceSyncAll(); + log.info("[初始化] 工单队列全量同步完成:同步 {} 条记录", count); + }); + } catch (Exception e) { + log.error("[初始化] 工单队列全量同步失败", e); + } + } +} From da00f082621df7b7ec6f08354c6dea005dce73cf Mon Sep 17 00:00:00 2001 From: lzh Date: Tue, 31 Mar 2026 22:58:40 +0800 Subject: [PATCH 08/16] =?UTF-8?q?fix(environment):=20=E5=AF=B9=E8=B4=A6?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=90=8E=E5=90=8C=E6=AD=A5=E6=B8=85=E7=90=86?= =?UTF-8?q?=20TTS=20=E5=BE=AA=E7=8E=AF=E6=92=AD=E6=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BadgeDeviceStatusSyncJob 在修复设备工单一致性后额外停止 TTS 循环\n- 避免工单已清除但语音循环标记残留,导致设备继续播报\n- 对 TTS 清理失败增加 warn 日志,避免影响主对账流程 --- .../ops/environment/job/BadgeDeviceStatusSyncJob.java | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/job/BadgeDeviceStatusSyncJob.java b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/job/BadgeDeviceStatusSyncJob.java index 2005e13f..fdb052ef 100644 --- a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/job/BadgeDeviceStatusSyncJob.java +++ b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/job/BadgeDeviceStatusSyncJob.java @@ -12,6 +12,7 @@ import com.viewsh.module.ops.dal.mysql.area.OpsAreaDeviceRelationMapper; import com.viewsh.module.ops.enums.BadgeDeviceStatusEnum; import com.viewsh.module.ops.environment.integration.dto.IotDeviceStatusChangedEventDTO; import com.viewsh.module.ops.environment.service.badge.BadgeDeviceStatusService; +import com.viewsh.module.ops.environment.service.voice.TtsQueueConsumer; import com.xxl.job.core.handler.annotation.XxlJob; import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; @@ -52,6 +53,9 @@ public class BadgeDeviceStatusSyncJob { @Resource private BadgeDeviceStatusService badgeDeviceStatusService; + @Resource + private TtsQueueConsumer ttsQueueConsumer; + /** * 执行全量对账 *

@@ -122,6 +126,12 @@ public class BadgeDeviceStatusSyncJob { boolean orderRepaired = badgeDeviceStatusService.repairDeviceOrderConsistency(iotStatus.getDeviceId()); if (orderRepaired) { orderRepairedCount++; + // 工单已清除,同时清理可能残留的 TTS 循环播报标记 + try { + ttsQueueConsumer.stopLoop(iotStatus.getDeviceId()); + } catch (Exception e) { + log.warn("[SyncJob] 清理TTS循环播报失败: deviceId={}", iotStatus.getDeviceId(), e); + } } // 4b. IoT 在线/离线状态对账 From 9780d6c3f7e9b62264f643b1c17d8dc78175dc0c Mon Sep 17 00:00:00 2001 From: lzh Date: Wed, 1 Apr 2026 09:23:27 +0800 Subject: [PATCH 09/16] =?UTF-8?q?fix(ops):=20=E5=8C=BA=E5=9F=9F=E8=AE=BE?= =?UTF-8?q?=E5=A4=87=20RPC=20=E6=8E=A5=E5=8F=A3=E6=B7=BB=E5=8A=A0=20@Tenan?= =?UTF-8?q?tIgnore=20=E8=A7=A3=E5=86=B3=E5=AE=9A=E6=97=B6=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E8=B0=83=E7=94=A8=E6=97=B6=E7=A7=9F=E6=88=B7=E4=B8=8A?= =?UTF-8?q?=E4=B8=8B=E6=96=87=E7=BC=BA=E5=A4=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IoT 模块 BeaconRegistryServiceImpl 每30分钟通过 Feign 调用 /beacons/all 接口, 因定时任务无租户上下文导致 TenantContextHolder NPE。对跨租户查询的方法添加 @TenantIgnore 注解忽略多租户过滤。 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../viewsh/module/ops/service/area/AreaDeviceServiceImpl.java | 4 ++++ 1 file changed, 4 insertions(+) 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 dc82847d..87f6eec8 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 @@ -1,6 +1,7 @@ package com.viewsh.module.ops.service.area; import com.viewsh.framework.common.util.json.JsonUtils; +import com.viewsh.framework.tenant.core.aop.TenantIgnore; import com.viewsh.module.ops.api.area.AreaDeviceDTO; import com.viewsh.module.ops.dal.dataobject.area.OpsAreaDeviceRelationDO; import com.viewsh.module.ops.dal.mysql.area.OpsAreaDeviceRelationMapper; @@ -164,16 +165,19 @@ public class AreaDeviceServiceImpl implements AreaDeviceService, InitializingBea } @Override + @TenantIgnore // RPC 接口,跨租户查询全量设备 public List listAllByType(String relationType) { return relationMapper.selectListByAreaIdAndRelationType(null, relationType); } @Override + @TenantIgnore // RPC 接口,跨租户查询全量 Beacon public List listAllEnabledBeacons() { return listAllByType("BEACON"); } @Override + @TenantIgnore // 启动预热,跨租户加载全量配置 public void initConfigCache() { log.info("[AreaDevice] 开始初始化区域设备配置缓存..."); From 368fa90156b1a646e67ff5192be6e5e478cd2fee Mon Sep 17 00:00:00 2001 From: lzh Date: Sun, 5 Apr 2026 15:25:47 +0800 Subject: [PATCH 10/16] =?UTF-8?q?refactor(ops):=20=E8=BD=A8=E8=BF=B9?= =?UTF-8?q?=E5=8C=BA=E5=9F=9F=E5=B1=95=E7=A4=BA=E6=94=B9=E7=94=A8=20fullAr?= =?UTF-8?q?eaName=20=E6=9B=BF=E4=BB=A3=20buildingName/floorNo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TrajectoryRespDTO 移除 buildingName、floorNo 字段,新增 fullAreaName (完整路径如"A园区/A栋/3层/男卫")。AreaPathBuilder 新增 buildPaths 批量方法,一次查询所有父级区域避免 N+1;正则预编译为静态常量。 Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../infrastructure/area/AreaPathBuilder.java | 93 ++++++++++++++++++- .../trajectory/dto/AreaStayStatsDTO.java | 33 +++++++ .../trajectory/dto/HourlyTrendDTO.java | 30 ++++++ .../trajectory/dto/TrajectoryRespDTO.java | 7 +- .../trajectory/dto/TrajectoryStatsReqDTO.java | 29 ++++++ 5 files changed, 182 insertions(+), 10 deletions(-) create mode 100644 viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/trajectory/dto/AreaStayStatsDTO.java create mode 100644 viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/trajectory/dto/HourlyTrendDTO.java create mode 100644 viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/trajectory/dto/TrajectoryStatsReqDTO.java diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/area/AreaPathBuilder.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/area/AreaPathBuilder.java index a37ea40e..d9e81019 100644 --- a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/area/AreaPathBuilder.java +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/area/AreaPathBuilder.java @@ -7,10 +7,8 @@ import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import java.util.Map; +import java.util.*; +import java.util.regex.Pattern; import java.util.stream.Collectors; /** @@ -25,6 +23,8 @@ import java.util.stream.Collectors; @Component public class AreaPathBuilder { + private static final Pattern DIGITS = Pattern.compile("\\d+"); + @Resource private OpsBusAreaMapper opsBusAreaMapper; @@ -47,7 +47,7 @@ public class AreaPathBuilder { // 解析父级ID列表 List parentIds = Arrays.stream(parentPath.split("/")) .filter(StrUtil::isNotBlank) - .filter(pid -> pid.matches("\\d+")) + .filter(pid -> DIGITS.matcher(pid).matches()) .map(Long::parseLong) .filter(pid -> !pid.equals(area.getId())) .collect(Collectors.toList()); @@ -105,4 +105,87 @@ public class AreaPathBuilder { return buildPath(area); } + /** + * 批量构建区域路径(只执行一次父级查询) + * + * @param areas 区域对象集合 + * @return areaId → 完整路径 的映射 + */ + public Map buildPaths(Collection areas) { + if (areas == null || areas.isEmpty()) { + return Collections.emptyMap(); + } + + // 1. 收集所有父级 ID + Set allParentIds = new HashSet<>(); + for (OpsBusAreaDO area : areas) { + if (StrUtil.isNotEmpty(area.getParentPath())) { + for (String idStr : area.getParentPath().split("/")) { + if (StrUtil.isNotBlank(idStr) && DIGITS.matcher(idStr).matches()) { + allParentIds.add(Long.parseLong(idStr)); + } + } + } + } + // 排除自身已在集合中的 + Set areaIds = areas.stream().map(OpsBusAreaDO::getId).collect(Collectors.toSet()); + allParentIds.removeAll(areaIds); + + // 2. 一次性查询所有父级 + Map parentNameMap = new HashMap<>(); + for (OpsBusAreaDO area : areas) { + parentNameMap.put(area.getId(), area.getAreaName()); + } + if (!allParentIds.isEmpty()) { + List parents = opsBusAreaMapper.selectBatchIds(allParentIds); + if (parents != null) { + for (OpsBusAreaDO parent : parents) { + parentNameMap.put(parent.getId(), parent.getAreaName()); + } + } + } + + // 3. 纯内存拼路径 + Map result = new HashMap<>(areas.size()); + for (OpsBusAreaDO area : areas) { + result.put(area.getId(), buildPathFromCache(area, parentNameMap)); + } + return result; + } + + /** + * 使用预加载的名称缓存构建路径(纯内存,无 DB 查询) + */ + private String buildPathFromCache(OpsBusAreaDO area, Map nameMap) { + String parentPath = area.getParentPath(); + if (StrUtil.isEmpty(parentPath)) { + return area.getAreaName(); + } + + List parentIds = Arrays.stream(parentPath.split("/")) + .filter(StrUtil::isNotBlank) + .filter(pid -> DIGITS.matcher(pid).matches()) + .map(Long::parseLong) + .filter(pid -> !pid.equals(area.getId())) + .collect(Collectors.toList()); + + // 去重相邻重复 + List segments = new ArrayList<>(); + Long lastId = null; + for (Long pid : parentIds) { + if (!pid.equals(lastId)) { + String name = nameMap.get(pid); + if (name != null) { + segments.add(name); + } + lastId = pid; + } + } + + if (segments.isEmpty()) { + return area.getAreaName(); + } + return String.join("/", segments) + "/" + area.getAreaName(); + } + } diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/trajectory/dto/AreaStayStatsDTO.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/trajectory/dto/AreaStayStatsDTO.java new file mode 100644 index 00000000..39c2840e --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/trajectory/dto/AreaStayStatsDTO.java @@ -0,0 +1,33 @@ +package com.viewsh.module.ops.service.trajectory.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 区域停留分布 Response DTO + * + * @author lzh + */ +@Schema(description = "管理后台 - 区域停留分布 Response DTO") +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AreaStayStatsDTO { + + @Schema(description = "区域名称", example = "男卫") + private String areaName; + + @Schema(description = "完整区域路径", example = "A园区/A栋/3层/男卫") + private String fullAreaName; + + @Schema(description = "总停留时长(秒)", example = "3600") + private Long totalStaySeconds; + + @Schema(description = "访问次数", example = "12") + private Long visitCount; + +} diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/trajectory/dto/HourlyTrendDTO.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/trajectory/dto/HourlyTrendDTO.java new file mode 100644 index 00000000..d523b61a --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/trajectory/dto/HourlyTrendDTO.java @@ -0,0 +1,30 @@ +package com.viewsh.module.ops.service.trajectory.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 时段出入趋势 Response DTO + * + * @author lzh + */ +@Schema(description = "管理后台 - 时段出入趋势 Response DTO") +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class HourlyTrendDTO { + + @Schema(description = "小时(0-23)", example = "8") + private Integer hour; + + @Schema(description = "进入次数", example = "8") + private Long enterCount; + + @Schema(description = "离开次数", example = "6") + private Long leaveCount; + +} diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/trajectory/dto/TrajectoryRespDTO.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/trajectory/dto/TrajectoryRespDTO.java index 07b4847a..96bfdd17 100644 --- a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/trajectory/dto/TrajectoryRespDTO.java +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/trajectory/dto/TrajectoryRespDTO.java @@ -32,11 +32,8 @@ public class TrajectoryRespDTO { @Schema(description = "区域名称", example = "A座2楼男卫") private String areaName; - @Schema(description = "楼栋名称", example = "A栋") - private String buildingName; - - @Schema(description = "楼层号", example = "2") - private Integer floorNo; + @Schema(description = "完整区域路径", example = "A园区/A栋/3层/男卫") + private String fullAreaName; @Schema(description = "Beacon MAC", example = "F0:C8:60:1D:10:BB") private String beaconMac; diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/trajectory/dto/TrajectoryStatsReqDTO.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/trajectory/dto/TrajectoryStatsReqDTO.java new file mode 100644 index 00000000..e670ad51 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/trajectory/dto/TrajectoryStatsReqDTO.java @@ -0,0 +1,29 @@ +package com.viewsh.module.ops.service.trajectory.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDate; + +/** + * 轨迹统计查询 Request DTO + *

+ * 用于 summary / hourly-trend / area-stay-stats 三个统计接口 + * + * @author lzh + */ +@Schema(description = "管理后台 - 轨迹统计查询 Request DTO") +@Data +public class TrajectoryStatsReqDTO { + + @Schema(description = "查询日期", requiredMode = Schema.RequiredMode.REQUIRED, example = "2026-04-05") + @NotNull(message = "查询日期不能为空") + @DateTimeFormat(pattern = "yyyy-MM-dd") + private LocalDate date; + + @Schema(description = "设备ID(不传=全部设备汇总)", example = "31") + private Long deviceId; + +} From 9ffaac5c91db070e32558c9967e23e50e0d37617 Mon Sep 17 00:00:00 2001 From: lzh Date: Sun, 5 Apr 2026 15:26:14 +0800 Subject: [PATCH 11/16] =?UTF-8?q?feat(ops):=20=E6=96=B0=E5=A2=9E=E8=BD=A8?= =?UTF-8?q?=E8=BF=B9=E7=BB=9F=E8=AE=A1=E6=8E=A5=E5=8F=A3=20summary/hourly-?= =?UTF-8?q?trend/area-stay-stats?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - summary: KPI 卡片(作业时长、覆盖区域数、事件数、平均停留) - hourly-trend: 按小时聚合出入趋势 - area-stay-stats: 区域停留分布(含 fullAreaName,按时长降序) - deviceId 可选,不传则汇总全部设备 - selectByDateAndDevice 加 LIMIT 5000 安全上限 - 删除无调用方的 selectTimeline 方法 - enrichWithAreaInfo 改用 buildPaths 批量构建路径 Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../trajectory/DeviceTrajectoryService.java | 25 ++- .../DeviceTrajectoryServiceImpl.java | 177 +++++++++--------- .../trajectory/OpsDeviceTrajectoryMapper.java | 11 +- .../trajectory/dto/TrajectorySummaryDTO.java | 20 +- .../trajectory/TrajectoryController.java | 20 +- 5 files changed, 141 insertions(+), 112 deletions(-) diff --git a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/trajectory/DeviceTrajectoryService.java b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/trajectory/DeviceTrajectoryService.java index ca011c24..5cb81d0d 100644 --- a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/trajectory/DeviceTrajectoryService.java +++ b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/trajectory/DeviceTrajectoryService.java @@ -1,9 +1,7 @@ package com.viewsh.module.ops.environment.service.trajectory; import com.viewsh.framework.common.pojo.PageResult; -import com.viewsh.module.ops.service.trajectory.dto.TrajectoryPageReqDTO; -import com.viewsh.module.ops.service.trajectory.dto.TrajectoryRespDTO; -import com.viewsh.module.ops.service.trajectory.dto.TrajectorySummaryDTO; +import com.viewsh.module.ops.service.trajectory.dto.*; import java.time.LocalDate; import java.time.LocalDateTime; @@ -55,11 +53,24 @@ public interface DeviceTrajectoryService { List getTimeline(Long deviceId, LocalDate date); /** - * 查询轨迹统计摘要 + * 查询轨迹统计摘要(KPI 卡片) * - * @param deviceId 设备ID(必填) - * @param date 日期(必填) + * @param req 查询条件(date 必填,deviceId 可选) */ - TrajectorySummaryDTO getSummary(Long deviceId, LocalDate date); + TrajectorySummaryDTO getSummary(TrajectoryStatsReqDTO req); + + /** + * 查询时段出入趋势(按小时聚合) + * + * @param req 查询条件(date 必填,deviceId 可选) + */ + List getHourlyTrend(TrajectoryStatsReqDTO req); + + /** + * 查询区域停留分布(按总停留时长降序) + * + * @param req 查询条件(date 必填,deviceId 可选) + */ + List getAreaStayStats(TrajectoryStatsReqDTO req); } diff --git a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/trajectory/DeviceTrajectoryServiceImpl.java b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/trajectory/DeviceTrajectoryServiceImpl.java index fa861418..6b1e85ca 100644 --- a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/trajectory/DeviceTrajectoryServiceImpl.java +++ b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/trajectory/DeviceTrajectoryServiceImpl.java @@ -6,9 +6,8 @@ import com.viewsh.module.ops.dal.dataobject.area.OpsBusAreaDO; import com.viewsh.module.ops.dal.dataobject.trajectory.OpsDeviceTrajectoryDO; import com.viewsh.module.ops.dal.mysql.area.OpsBusAreaMapper; import com.viewsh.module.ops.dal.mysql.trajectory.OpsDeviceTrajectoryMapper; -import com.viewsh.module.ops.service.trajectory.dto.TrajectoryPageReqDTO; -import com.viewsh.module.ops.service.trajectory.dto.TrajectoryRespDTO; -import com.viewsh.module.ops.service.trajectory.dto.TrajectorySummaryDTO; +import com.viewsh.module.ops.infrastructure.area.AreaPathBuilder; +import com.viewsh.module.ops.service.trajectory.dto.*; import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -37,6 +36,9 @@ public class DeviceTrajectoryServiceImpl implements DeviceTrajectoryService { @Resource private OpsBusAreaMapper areaMapper; + @Resource + private AreaPathBuilder areaPathBuilder; + // ==================== 写入方法 ==================== @Override @@ -119,53 +121,117 @@ public class DeviceTrajectoryServiceImpl implements DeviceTrajectoryService { @Override public List getTimeline(Long deviceId, LocalDate date) { - List list = trajectoryMapper.selectTimeline(deviceId, date); + List list = trajectoryMapper.selectByDateAndDevice(date, deviceId); List result = BeanUtils.toBean(list, TrajectoryRespDTO.class); enrichWithAreaInfo(result); return result; } @Override - public TrajectorySummaryDTO getSummary(Long deviceId, LocalDate date) { - List list = trajectoryMapper.selectTimeline(deviceId, date); + public TrajectorySummaryDTO getSummary(TrajectoryStatsReqDTO req) { + List list = trajectoryMapper.selectByDateAndDevice(req.getDate(), req.getDeviceId()); if (list.isEmpty()) { return TrajectorySummaryDTO.builder() - .totalRecords(0L) - .completedRecords(0L) + .workDurationSeconds(0L) .coveredAreaCount(0L) - .totalDurationSeconds(0L) - .avgDurationSeconds(0L) - .maxDurationSeconds(0L) + .totalEvents(0L) + .avgStaySeconds(0L) .build(); } - long totalRecords = list.size(); + long totalEvents = list.size(); long coveredAreaCount = list.stream() .map(OpsDeviceTrajectoryDO::getAreaId) .distinct() .count(); - // 只统计已关闭(有 durationSeconds)的记录 List durations = list.stream() .map(OpsDeviceTrajectoryDO::getDurationSeconds) .filter(Objects::nonNull) .toList(); - long completedRecords = durations.size(); - long totalDuration = durations.stream().mapToLong(Integer::longValue).sum(); - long avgDuration = durations.isEmpty() ? 0 : totalDuration / durations.size(); - long maxDuration = durations.stream().mapToInt(Integer::intValue).max().orElse(0); + long workDuration = durations.stream().mapToLong(Integer::longValue).sum(); + long avgStay = durations.isEmpty() ? 0 : workDuration / durations.size(); return TrajectorySummaryDTO.builder() - .totalRecords(totalRecords) - .completedRecords(completedRecords) + .workDurationSeconds(workDuration) .coveredAreaCount(coveredAreaCount) - .totalDurationSeconds(totalDuration) - .avgDurationSeconds(avgDuration) - .maxDurationSeconds(maxDuration) + .totalEvents(totalEvents) + .avgStaySeconds(avgStay) .build(); } + @Override + public List getHourlyTrend(TrajectoryStatsReqDTO req) { + List list = trajectoryMapper.selectByDateAndDevice(req.getDate(), req.getDeviceId()); + if (list.isEmpty()) { + return Collections.emptyList(); + } + + // 按小时聚合进入/离开次数 + Map hourMap = new TreeMap<>(); + for (OpsDeviceTrajectoryDO record : list) { + if (record.getEnterTime() != null) { + int hour = record.getEnterTime().getHour(); + hourMap.computeIfAbsent(hour, k -> new long[2])[0]++; + } + if (record.getLeaveTime() != null) { + int hour = record.getLeaveTime().getHour(); + hourMap.computeIfAbsent(hour, k -> new long[2])[1]++; + } + } + + return hourMap.entrySet().stream() + .map(e -> HourlyTrendDTO.builder() + .hour(e.getKey()) + .enterCount(e.getValue()[0]) + .leaveCount(e.getValue()[1]) + .build()) + .collect(Collectors.toList()); + } + + @Override + public List getAreaStayStats(TrajectoryStatsReqDTO req) { + List list = trajectoryMapper.selectByDateAndDevice(req.getDate(), req.getDeviceId()); + if (list.isEmpty()) { + return Collections.emptyList(); + } + + // 按 areaId 聚合 + Map areaStatsMap = new LinkedHashMap<>(); + Map areaNameMap = new HashMap<>(); + for (OpsDeviceTrajectoryDO record : list) { + if (record.getAreaId() == null) { + continue; + } + long[] stats = areaStatsMap.computeIfAbsent(record.getAreaId(), k -> new long[2]); + stats[0] += record.getDurationSeconds() != null ? record.getDurationSeconds() : 0; + stats[1]++; + areaNameMap.putIfAbsent(record.getAreaId(), record.getAreaName()); + } + + // 批量查区域 + 一次性构建 fullAreaName + List areas = areaMapper.selectBatchIds(areaStatsMap.keySet()); + Map areaMap = areas.stream() + .collect(Collectors.toMap(OpsBusAreaDO::getId, a -> a, (a, b) -> a)); + Map fullAreaNameMap = areaPathBuilder.buildPaths(areas); + + return areaStatsMap.entrySet().stream() + .map(e -> { + Long areaId = e.getKey(); + long[] stats = e.getValue(); + OpsBusAreaDO area = areaMap.get(areaId); + return AreaStayStatsDTO.builder() + .areaName(area != null ? area.getAreaName() : areaNameMap.get(areaId)) + .fullAreaName(fullAreaNameMap.get(areaId)) + .totalStaySeconds(stats[0]) + .visitCount(stats[1]) + .build(); + }) + .sorted((a, b) -> Long.compare(b.getTotalStaySeconds(), a.getTotalStaySeconds())) + .collect(Collectors.toList()); + } + // ==================== 内部方法 ==================== /** @@ -186,7 +252,7 @@ public class DeviceTrajectoryServiceImpl implements DeviceTrajectoryService { } /** - * 批量填充轨迹记录的区域信息(areaName、buildingName、floorNo) + * 批量填充轨迹记录的区域信息(areaName、fullAreaName) */ private void enrichWithAreaInfo(List list) { if (list == null || list.isEmpty()) { @@ -203,39 +269,11 @@ public class DeviceTrajectoryServiceImpl implements DeviceTrajectoryService { return; } - // 批量查询区域 + // 批量查询区域 + 一次性构建 fullAreaName List areas = areaMapper.selectBatchIds(areaIds); Map areaMap = areas.stream() .collect(Collectors.toMap(OpsBusAreaDO::getId, a -> a, (a, b) -> a)); - - // 收集需要查询的父级 ID(楼栋信息) - Set parentIds = new HashSet<>(); - for (OpsBusAreaDO area : areas) { - if (area.getParentPath() != null && !area.getParentPath().isEmpty()) { - // parentPath 格式: "/1/2/3",提取所有祖先 ID - for (String idStr : area.getParentPath().split("/")) { - if (!idStr.isEmpty()) { - try { - parentIds.add(Long.parseLong(idStr)); - } catch (NumberFormatException ignored) { - } - } - } - } - if (area.getParentId() != null) { - parentIds.add(area.getParentId()); - } - } - parentIds.removeAll(areaIds); // 排除已查过的 - - // 批量查询父级区域 - Map allAreaMap = new HashMap<>(areaMap); - if (!parentIds.isEmpty()) { - List parentAreas = areaMapper.selectBatchIds(parentIds); - for (OpsBusAreaDO pa : parentAreas) { - allAreaMap.put(pa.getId(), pa); - } - } + Map fullAreaNameMap = areaPathBuilder.buildPaths(areas); // 填充每条记录 for (TrajectoryRespDTO dto : list) { @@ -247,38 +285,7 @@ public class DeviceTrajectoryServiceImpl implements DeviceTrajectoryService { continue; } dto.setAreaName(area.getAreaName()); - dto.setFloorNo(area.getFloorNo()); - dto.setBuildingName(findBuildingName(area, allAreaMap)); - - // 如果 floorNo 为空,尝试从 FLOOR 类型的父级获取 - if (dto.getFloorNo() == null && area.getParentId() != null) { - OpsBusAreaDO parent = allAreaMap.get(area.getParentId()); - if (parent != null && "FLOOR".equals(parent.getAreaType())) { - dto.setFloorNo(parent.getFloorNo()); - } - } + dto.setFullAreaName(fullAreaNameMap.get(dto.getAreaId())); } } - - /** - * 沿 parentPath 向上查找 BUILDING 类型的祖先区域名称 - */ - private String findBuildingName(OpsBusAreaDO area, Map allAreaMap) { - if (area.getParentPath() == null) { - return null; - } - for (String idStr : area.getParentPath().split("/")) { - if (idStr.isEmpty()) { - continue; - } - try { - OpsBusAreaDO ancestor = allAreaMap.get(Long.parseLong(idStr)); - if (ancestor != null && "BUILDING".equals(ancestor.getAreaType())) { - return ancestor.getAreaName(); - } - } catch (NumberFormatException ignored) { - } - } - return null; - } } diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/mysql/trajectory/OpsDeviceTrajectoryMapper.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/mysql/trajectory/OpsDeviceTrajectoryMapper.java index b7f06ceb..07ee9745 100644 --- a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/mysql/trajectory/OpsDeviceTrajectoryMapper.java +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/mysql/trajectory/OpsDeviceTrajectoryMapper.java @@ -68,16 +68,19 @@ public interface OpsDeviceTrajectoryMapper extends BaseMapperX + * 安全上限 5000 条,防止全量加载过多数据到内存 */ - default List selectTimeline(Long deviceId, LocalDate date) { + default List selectByDateAndDevice(LocalDate date, Long deviceId) { LocalDateTime start = date.atStartOfDay(); LocalDateTime end = date.plusDays(1).atStartOfDay(); return selectList(new LambdaQueryWrapperX() - .eq(OpsDeviceTrajectoryDO::getDeviceId, deviceId) + .eqIfPresent(OpsDeviceTrajectoryDO::getDeviceId, deviceId) .ge(OpsDeviceTrajectoryDO::getEnterTime, start) .lt(OpsDeviceTrajectoryDO::getEnterTime, end) - .orderByAsc(OpsDeviceTrajectoryDO::getEnterTime)); + .orderByAsc(OpsDeviceTrajectoryDO::getEnterTime) + .last("LIMIT 5000")); } } diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/trajectory/dto/TrajectorySummaryDTO.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/trajectory/dto/TrajectorySummaryDTO.java index 1f651fc8..4d4a5bb6 100644 --- a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/trajectory/dto/TrajectorySummaryDTO.java +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/trajectory/dto/TrajectorySummaryDTO.java @@ -20,22 +20,16 @@ import lombok.NoArgsConstructor; @AllArgsConstructor public class TrajectorySummaryDTO { - @Schema(description = "总轨迹记录数(含未关闭)", example = "42") - private Long totalRecords; + @Schema(description = "总作业时长(秒)", example = "7200") + private Long workDurationSeconds; - @Schema(description = "已完成记录数(有离开时间)", example = "38") - private Long completedRecords; - - @Schema(description = "覆盖区域数", example = "8") + @Schema(description = "覆盖区域数", example = "5") private Long coveredAreaCount; - @Schema(description = "总停留时长(秒)", example = "28800") - private Long totalDurationSeconds; + @Schema(description = "总出入事件数", example = "42") + private Long totalEvents; - @Schema(description = "平均停留时长(秒)", example = "685") - private Long avgDurationSeconds; - - @Schema(description = "最长单次停留(秒)", example = "3600") - private Long maxDurationSeconds; + @Schema(description = "平均停留时长(秒)", example = "171") + private Long avgStaySeconds; } diff --git a/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/trajectory/TrajectoryController.java b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/trajectory/TrajectoryController.java index 28f15e10..2641319f 100644 --- a/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/trajectory/TrajectoryController.java +++ b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/trajectory/TrajectoryController.java @@ -126,10 +126,24 @@ public class TrajectoryController { } @GetMapping("/summary") - @Operation(summary = "获得设备某天的轨迹统计摘要") + @Operation(summary = "获得轨迹统计摘要(KPI 卡片)") @PreAuthorize("@ss.hasPermission('ops:trajectory:query')") - public CommonResult getSummary(@Valid TrajectoryTimelineReqDTO req) { - return success(trajectoryService.getSummary(req.getDeviceId(), req.getDate())); + public CommonResult getSummary(@Valid TrajectoryStatsReqDTO req) { + return success(trajectoryService.getSummary(req)); + } + + @GetMapping("/hourly-trend") + @Operation(summary = "获得时段出入趋势(按小时聚合)") + @PreAuthorize("@ss.hasPermission('ops:trajectory:query')") + public CommonResult> getHourlyTrend(@Valid TrajectoryStatsReqDTO req) { + return success(trajectoryService.getHourlyTrend(req)); + } + + @GetMapping("/area-stay-stats") + @Operation(summary = "获得区域停留分布") + @PreAuthorize("@ss.hasPermission('ops:trajectory:query')") + public CommonResult> getAreaStayStats(@Valid TrajectoryStatsReqDTO req) { + return success(trajectoryService.getAreaStayStats(req)); } // ==================== 实时位置 ==================== From 54f78f8066b89c931f9466d953f750e8e1f28bfd Mon Sep 17 00:00:00 2001 From: lzh Date: Sun, 5 Apr 2026 15:26:43 +0800 Subject: [PATCH 12/16] =?UTF-8?q?feat(ops):=20=E5=B7=A5=E7=89=8C=E5=AE=9E?= =?UTF-8?q?=E6=97=B6=E7=8A=B6=E6=80=81=E5=A2=9E=E5=8A=A0=E7=89=A9=E7=90=86?= =?UTF-8?q?=E4=BD=8D=E7=BD=AE=E3=80=81=E7=94=B5=E9=87=8F=E5=92=8C=E5=B7=A5?= =?UTF-8?q?=E5=8D=95=E4=BF=A1=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BadgeRealtimeStatusRespDTO 新增物理位置(IoT 轨迹检测 RPC)、 电量(IoT 设备属性 RPC)、当前工单信息三个维度。 RPC 调用改为串行执行避免占用 ForkJoinPool 公共线程。 设备状态写入 Redis 时同步写入区域名称。 Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../badge/BadgeDeviceStatusServiceImpl.java | 14 +++ .../service/badge/CleanBadgeServiceImpl.java | 119 ++++++++++++++++-- .../api/clean/BadgeRealtimeStatusRespDTO.java | 34 +++-- .../rpc/config/RpcConfiguration.java | 2 + 4 files changed, 151 insertions(+), 18 deletions(-) diff --git a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/badge/BadgeDeviceStatusServiceImpl.java b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/badge/BadgeDeviceStatusServiceImpl.java index e5c05408..12164ff2 100644 --- a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/badge/BadgeDeviceStatusServiceImpl.java +++ b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/badge/BadgeDeviceStatusServiceImpl.java @@ -1,7 +1,9 @@ package com.viewsh.module.ops.environment.service.badge; import com.viewsh.module.ops.api.badge.BadgeDeviceStatusDTO; +import com.viewsh.module.ops.dal.dataobject.area.OpsBusAreaDO; import com.viewsh.module.ops.dal.dataobject.workorder.OpsOrderDO; +import com.viewsh.module.ops.dal.mysql.area.OpsBusAreaMapper; import com.viewsh.module.ops.dal.mysql.workorder.OpsOrderMapper; import com.viewsh.module.ops.enums.BadgeDeviceStatusEnum; import com.viewsh.module.ops.enums.WorkOrderStatusEnum; @@ -48,6 +50,9 @@ public class BadgeDeviceStatusServiceImpl implements BadgeDeviceStatusService, I @Resource private OpsOrderMapper opsOrderMapper; + @Resource + private OpsBusAreaMapper opsBusAreaMapper; + @Resource private ApplicationEventPublisher eventPublisher; @@ -400,6 +405,15 @@ public class BadgeDeviceStatusServiceImpl implements BadgeDeviceStatusService, I } if (areaId != null) { stringRedisTemplate.opsForHash().put(key, "currentAreaId", String.valueOf(areaId)); + // 同步写入区域名称 + try { + OpsBusAreaDO area = opsBusAreaMapper.selectById(areaId); + if (area != null && area.getAreaName() != null) { + stringRedisTemplate.opsForHash().put(key, "currentAreaName", area.getAreaName()); + } + } catch (Exception e) { + log.warn("查询区域名称失败: areaId={}", areaId, e); + } } if (beaconMac != null) { stringRedisTemplate.opsForHash().put(key, "beaconMac", beaconMac); diff --git a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/badge/CleanBadgeServiceImpl.java b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/badge/CleanBadgeServiceImpl.java index 3ad62fb2..7f145248 100644 --- a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/badge/CleanBadgeServiceImpl.java +++ b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/badge/CleanBadgeServiceImpl.java @@ -1,10 +1,16 @@ package com.viewsh.module.ops.environment.service.badge; +import com.viewsh.framework.common.pojo.CommonResult; import com.viewsh.module.iot.api.device.IotDeviceControlApi; +import com.viewsh.module.iot.api.device.IotDevicePropertyQueryApi; import com.viewsh.module.iot.api.device.dto.IotDeviceServiceInvokeReqDTO; +import com.viewsh.module.iot.api.trajectory.DeviceLocationDTO; +import com.viewsh.module.iot.api.trajectory.TrajectoryStateApi; import com.viewsh.module.ops.api.badge.BadgeDeviceStatusDTO; import com.viewsh.module.ops.api.clean.BadgeRealtimeStatusRespDTO; import com.viewsh.module.ops.api.clean.BadgeStatusRespDTO; +import com.viewsh.module.ops.dal.dataobject.area.OpsBusAreaDO; +import com.viewsh.module.ops.dal.mysql.area.OpsBusAreaMapper; import com.viewsh.module.ops.environment.service.badge.dto.BadgeNotifyReqDTO; import com.viewsh.module.ops.service.area.AreaDeviceService; import jakarta.annotation.Resource; @@ -39,8 +45,22 @@ public class CleanBadgeServiceImpl implements CleanBadgeService { @Resource private IotDeviceControlApi iotDeviceControlApi; + @Resource + private TrajectoryStateApi trajectoryStateApi; + + @Resource + private IotDevicePropertyQueryApi iotDevicePropertyQueryApi; + + @Resource + private OpsBusAreaMapper opsBusAreaMapper; + private static final String NOTIFY_IDENTIFIER = "NOTIFY"; + /** + * IoT 设备属性标识符:电池电量 + */ + private static final String BATTERY_LEVEL_IDENTIFIER = "batteryLevel"; + @Override public List getBadgeStatusList(Long areaId, String status) { try { @@ -83,28 +103,52 @@ public class CleanBadgeServiceImpl implements CleanBadgeService { } } + /** + * 获取工牌实时状态详情 + *

+ * 聚合三个数据源: + * 1. 工牌设备状态(Redis,本地) + * 2. 工牌物理位置(IoT TrajectoryStateApi,RPC) + * 3. 设备电量(IoT IotDevicePropertyQueryApi,RPC) + */ @Override public BadgeRealtimeStatusRespDTO getBadgeRealtimeStatus(Long badgeId) { try { - // 1. 获取工牌状态 + // 1. 获取工牌设备状态(Redis) BadgeDeviceStatusDTO status = badgeDeviceStatusService.getBadgeStatus(badgeId); if (status == null) { log.warn("[getBadgeRealtimeStatus] 工牌状态不存在: badgeId={}", badgeId); return null; } - // 2. 构建响应 - return BadgeRealtimeStatusRespDTO.builder() + // 2. 查询工牌物理位置 + 电量 + DeviceLocationDTO location = queryPhysicalLocation(badgeId); + Integer batteryLevel = queryBatteryLevel(badgeId); + + // 3. 组装响应 + BadgeRealtimeStatusRespDTO.BadgeRealtimeStatusRespDTOBuilder builder = BadgeRealtimeStatusRespDTO.builder() .deviceId(status.getDeviceId()) .deviceKey(status.getDeviceCode()) .status(status.getStatusCode()) - .batteryLevel(status.getBatteryLevel()) - .lastHeartbeatTime(formatTimestamp(status.getLastHeartbeatTime())) - .rssi(null) // RSSI 需要从 IoT 模块获取,暂不实现 - .isInArea(status.getCurrentAreaId() != null) - .areaId(status.getCurrentAreaId()) - .areaName(status.getCurrentAreaName()) - .build(); + .batteryLevel(batteryLevel) + .onlineTime(formatTimestamp(status.getLastHeartbeatTime())); + + // 物理位置 + if (location != null && Boolean.TRUE.equals(location.getInArea())) { + builder.isInArea(true) + .areaId(location.getAreaId()) + .areaName(queryAreaNameById(location.getAreaId())); + } else { + builder.isInArea(false); + } + + // 当前工单信息 + builder.currentOrderId(status.getCurrentOpsOrderId()) + .currentOrderStatus(status.getCurrentOrderStatus()) + .orderAreaId(status.getCurrentAreaId()) + .orderAreaName(status.getCurrentAreaName()); + + return builder.build(); } catch (Exception e) { log.error("[getBadgeRealtimeStatus] 查询工牌实时状态失败: badgeId={}", badgeId, e); @@ -112,6 +156,61 @@ public class CleanBadgeServiceImpl implements CleanBadgeService { } } + /** + * 查询工牌物理位置(来自 IoT 轨迹检测 RPC) + * + * @return 位置信息,查询失败返回 null + */ + private DeviceLocationDTO queryPhysicalLocation(Long badgeId) { + try { + CommonResult result = trajectoryStateApi.getCurrentLocation(badgeId); + if (result != null && result.isSuccess()) { + return result.getData(); + } + return null; + } catch (Exception e) { + log.warn("[getBadgeRealtimeStatus] 查询工牌物理位置失败,降级为不在区域: badgeId={}", badgeId, e); + return null; + } + } + + /** + * 查询电量(来自 IoT 设备属性 RPC) + * + * @return 电量百分比(0-100),查询失败返回 null + */ + private Integer queryBatteryLevel(Long badgeId) { + try { + CommonResult> result = iotDevicePropertyQueryApi.getLatestProperties(badgeId); + if (result != null && result.isSuccess() && result.getData() != null) { + Object batteryObj = result.getData().get(BATTERY_LEVEL_IDENTIFIER); + if (batteryObj instanceof Number) { + return ((Number) batteryObj).intValue(); + } + } + return null; + } catch (Exception e) { + log.warn("[getBadgeRealtimeStatus] 查询工牌电量失败: badgeId={}", badgeId, e); + return null; + } + } + + /** + * 根据区域ID查询区域名称 + */ + private String queryAreaNameById(Long areaId) { + if (areaId == null) { + return null; + } + try { + OpsBusAreaDO area = opsBusAreaMapper.selectById(areaId); + return area != null ? area.queryAreaNameById() : null; + } catch (Exception e) { + log.warn("[getBadgeRealtimeStatus] 查询区域名称失败: areaId={}", areaId, e); + return null; + } + } + @Override public void sendBadgeNotify(BadgeNotifyReqDTO req) { try { diff --git a/viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/api/clean/BadgeRealtimeStatusRespDTO.java b/viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/api/clean/BadgeRealtimeStatusRespDTO.java index ecdf804d..68dd347f 100644 --- a/viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/api/clean/BadgeRealtimeStatusRespDTO.java +++ b/viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/api/clean/BadgeRealtimeStatusRespDTO.java @@ -9,7 +9,10 @@ import lombok.NoArgsConstructor; /** * 工牌实时状态详情响应 DTO *

- * 用于工牌详情页展示 + * 用于工牌详情页展示,包含: + * - 设备基础信息(状态、电量、上线时间) + * - 工牌物理位置(来自 IoT 轨迹检测) + * - 当前工单信息(来自工牌状态 Redis) * * @author lzh */ @@ -20,6 +23,8 @@ import lombok.NoArgsConstructor; @AllArgsConstructor public class BadgeRealtimeStatusRespDTO { + // ==================== 设备基础信息 ==================== + @Schema(description = "设备ID", example = "3001") private Long deviceId; @@ -32,18 +37,31 @@ public class BadgeRealtimeStatusRespDTO { @Schema(description = "电量(0-100)", example = "72") private Integer batteryLevel; - @Schema(description = "最后心跳时间", example = "2026-01-23 15:00:30") - private String lastHeartbeatTime; + @Schema(description = "设备上线时间", example = "2026-01-23 15:00:30") + private String onlineTime; - @Schema(description = "信号强度(dBm)", example = "-42") - private Integer rssi; + // ==================== 工牌物理位置(轨迹检测) ==================== - @Schema(description = "是否在区域内", example = "true") + @Schema(description = "是否在区域内(基于 IoT 信标检测)", example = "true") private Boolean isInArea; - @Schema(description = "当前区域ID", example = "101") + @Schema(description = "当前物理所在区域ID", example = "101") private Long areaId; - @Schema(description = "当前区域名称", example = "A区洗手间") + @Schema(description = "当前物理所在区域名称", example = "A区洗手间") private String areaName; + + // ==================== 当前工单信息 ==================== + + @Schema(description = "当前工单ID", example = "1234567890") + private Long currentOrderId; + + @Schema(description = "当前工单状态", example = "ARRIVED") + private String currentOrderStatus; + + @Schema(description = "工单目标区域ID", example = "101") + private Long orderAreaId; + + @Schema(description = "工单目标区域名称", example = "A区洗手间") + private String orderAreaName; } diff --git a/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/framework/rpc/config/RpcConfiguration.java b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/framework/rpc/config/RpcConfiguration.java index d9a1f7bc..b1934142 100644 --- a/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/framework/rpc/config/RpcConfiguration.java +++ b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/framework/rpc/config/RpcConfiguration.java @@ -2,6 +2,7 @@ package com.viewsh.module.ops.framework.rpc.config; import com.viewsh.module.infra.api.file.FileApi; import com.viewsh.module.iot.api.device.IotDeviceControlApi; +import com.viewsh.module.iot.api.device.IotDevicePropertyQueryApi; import com.viewsh.module.iot.api.device.IotDeviceQueryApi; import com.viewsh.module.iot.api.device.IotDeviceStatusQueryApi; import com.viewsh.module.iot.api.trajectory.TrajectoryStateApi; @@ -17,6 +18,7 @@ import org.springframework.context.annotation.Configuration; AdminUserApi.class, SocialUserApi.class, IotDeviceControlApi.class, + IotDevicePropertyQueryApi.class, IotDeviceQueryApi.class, IotDeviceStatusQueryApi.class, TrajectoryStateApi.class, From b379fc6741dde1d01b7d2d6e0596b48be47a2a09 Mon Sep 17 00:00:00 2001 From: lzh Date: Sun, 5 Apr 2026 15:27:05 +0800 Subject: [PATCH 13/16] =?UTF-8?q?feat(ops):=20timeline=20=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=20deviceId=20=E6=94=B9=E4=B8=BA=E5=8F=AF=E9=80=89?= =?UTF-8?q?=EF=BC=8C=E6=94=AF=E6=8C=81=E5=85=A8=E8=AE=BE=E5=A4=87=E6=9F=A5?= =?UTF-8?q?=E8=AF=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 不传 deviceId 时查询该日期所有设备的轨迹记录,复用 selectByDateAndDevice 的 LIMIT 5000 安全上限。 Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../ops/service/trajectory/dto/TrajectoryTimelineReqDTO.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/trajectory/dto/TrajectoryTimelineReqDTO.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/trajectory/dto/TrajectoryTimelineReqDTO.java index d76acad4..d6b80943 100644 --- a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/trajectory/dto/TrajectoryTimelineReqDTO.java +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/trajectory/dto/TrajectoryTimelineReqDTO.java @@ -16,8 +16,7 @@ import java.time.LocalDate; @Data public class TrajectoryTimelineReqDTO { - @Schema(description = "设备ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "31") - @NotNull(message = "设备ID不能为空") + @Schema(description = "设备ID(不传=全部设备)", example = "31") private Long deviceId; @Schema(description = "查询日期", requiredMode = Schema.RequiredMode.REQUIRED, example = "2026-03-30") From 04c61a41dbc1ae526c430e4dc457104d0659bfe3 Mon Sep 17 00:00:00 2001 From: lzh Date: Tue, 7 Apr 2026 10:45:10 +0800 Subject: [PATCH 14/16] =?UTF-8?q?fix(ops):=20=E4=BF=AE=E5=A4=8D=20CleanBad?= =?UTF-8?q?geServiceImpl=20=E8=B0=83=E7=94=A8=E4=B8=8D=E5=AD=98=E5=9C=A8?= =?UTF-8?q?=E7=9A=84=20queryAreaNameById=20=E6=96=B9=E6=B3=95=E5=AF=BC?= =?UTF-8?q?=E8=87=B4=E7=BC=96=E8=AF=91=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改用 OpsBusAreaDO.getAreaName() 获取区域名称 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ops/environment/service/badge/CleanBadgeServiceImpl.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/badge/CleanBadgeServiceImpl.java b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/badge/CleanBadgeServiceImpl.java index 7f145248..5ef2481f 100644 --- a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/badge/CleanBadgeServiceImpl.java +++ b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/badge/CleanBadgeServiceImpl.java @@ -204,7 +204,7 @@ public class CleanBadgeServiceImpl implements CleanBadgeService { } try { OpsBusAreaDO area = opsBusAreaMapper.selectById(areaId); - return area != null ? area.queryAreaNameById() : null; + return area != null ? area.getAreaName() : null; } catch (Exception e) { log.warn("[getBadgeRealtimeStatus] 查询区域名称失败: areaId={}", areaId, e); return null; From c8ba3e63cb4d24ba982239bffee3b9abadbdc0d4 Mon Sep 17 00:00:00 2001 From: lzh Date: Tue, 7 Apr 2026 14:59:53 +0800 Subject: [PATCH 15/16] =?UTF-8?q?feat(iot):=20=E6=96=B0=E5=A2=9E=E6=81=92?= =?UTF-8?q?=E5=8D=8ED5=E5=AE=A2=E6=B5=81=E6=91=84=E5=83=8F=E6=9C=BA?= =?UTF-8?q?=E7=BC=96=E8=A7=A3=E7=A0=81=E5=99=A8=EF=BC=8C=E5=AF=B9=E6=8E=A5?= =?UTF-8?q?=E6=8B=8C=E7=BA=BF=E4=BA=BA=E6=95=B0=E7=BB=9F=E8=AE=A1=EF=BC=88?= =?UTF-8?q?type=3D1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 走通用路由,新增 IotHenghuaD5Codec 解析 form-urlencoded 格式数据, 映射 InNum/OutNum 到 people_in/people_out,业务层完全复用现有客流阈值逻辑。 IotHttpUpstreamHandler 增加恒华D5 专用简洁响应。 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../codec/henghua/IotHenghuaD5Codec.java | 112 ++++++++++++++++++ .../codec/henghua/dto/HenghuaD5DataReqVO.java | 41 +++++++ .../http/router/IotHttpUpstreamHandler.java | 22 +++- 3 files changed, 172 insertions(+), 3 deletions(-) create mode 100644 viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/henghua/IotHenghuaD5Codec.java create mode 100644 viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/henghua/dto/HenghuaD5DataReqVO.java diff --git a/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/henghua/IotHenghuaD5Codec.java b/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/henghua/IotHenghuaD5Codec.java new file mode 100644 index 00000000..702f61bf --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/henghua/IotHenghuaD5Codec.java @@ -0,0 +1,112 @@ +package com.viewsh.module.iot.gateway.codec.henghua; + +import cn.hutool.core.map.MapUtil; +import com.viewsh.framework.common.util.json.JsonUtils; +import com.viewsh.module.iot.core.enums.IotDeviceMessageMethodEnum; +import com.viewsh.module.iot.core.mq.message.IotDeviceMessage; +import com.viewsh.module.iot.gateway.codec.IotDeviceMessageCodec; +import com.viewsh.module.iot.gateway.codec.henghua.dto.HenghuaD5DataReqVO; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.HashMap; +import java.util.Map; + +/** + * 恒华 D5 客流摄像机 编解码器 + */ +@Slf4j +@Component +public class IotHenghuaD5Codec implements IotDeviceMessageCodec { + + public static final String TYPE = "HENGHUA_D5"; + + @Override + public String type() { + return TYPE; + } + + @Override + public IotDeviceMessage decode(byte[] bytes) { + // 1. 将 byte[] 转为 UTF-8 字符串 + String body = new String(bytes, StandardCharsets.UTF_8); + + // 2. 按 & 拆分参数,构建 key-value Map(value 做 URL decode) + Map params = new HashMap<>(); + for (String entry : body.split("&")) { + int idx = entry.indexOf('='); + if (idx > 0) { + String key = URLDecoder.decode(entry.substring(0, idx), StandardCharsets.UTF_8); + String value = URLDecoder.decode(entry.substring(idx + 1), StandardCharsets.UTF_8); + params.put(key, value); + } + } + + // 3. 提取 status:等于 "0" 时为心跳,直接返回 null + String status = params.get("status"); + if ("0".equals(status)) { + return null; + } + + // 4. 提取 type:当前只对接 type=1 + String type = params.get("type"); + if (!"1".equals(type)) { + return null; + } + + // 5. 提取 data 参数(已在步骤 2 中完成 URL decode) + String dataJson = params.get("data"); + if (dataJson == null) { + log.warn("[decode][恒华D5 data 参数缺失]"); + return null; + } + + // 6. 将 JSON 字符串解析为 HenghuaD5DataReqVO + HenghuaD5DataReqVO reqVO; + try { + reqVO = JsonUtils.parseObject(dataJson, HenghuaD5DataReqVO.class); + } catch (Exception e) { + log.error("[decode][解析恒华D5 data JSON 失败]", e); + throw new IllegalArgumentException("data JSON 格式错误"); + } + if (reqVO == null) { + log.warn("[decode][恒华D5 data 解析结果为空]"); + return null; + } + + // 7. 构建属性 Map + Map propertyParams = MapUtil.newHashMap(); + propertyParams.put("people_in", reqVO.getInNum()); + propertyParams.put("people_out", reqVO.getOutNum()); + + // 8. 时间校验:timeStamp 超过 24 小时的数据舍弃 + LocalDateTime reportTime = LocalDateTime.now(); + if (reqVO.getTimeStamp() != null) { + reportTime = LocalDateTime.ofInstant( + Instant.ofEpochSecond(reqVO.getTimeStamp()), ZoneId.systemDefault()); + long hoursDiff = Math.abs(Duration.between(reportTime, LocalDateTime.now()).toHours()); + if (hoursDiff > 24) { + log.warn("[decode][设备时间超过24小时,舍弃数据: timeStamp={}, 时间差: {}小时]", + reqVO.getTimeStamp(), hoursDiff); + return null; + } + } + + // 9. 返回消息 + return IotDeviceMessage.requestOf(IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod(), propertyParams) + .setReportTime(reportTime); + } + + @Override + public byte[] encode(IotDeviceMessage message) { + // 恒华D5 不需要下行指令 + return new byte[0]; + } + +} diff --git a/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/henghua/dto/HenghuaD5DataReqVO.java b/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/henghua/dto/HenghuaD5DataReqVO.java new file mode 100644 index 00000000..f93dbab1 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/codec/henghua/dto/HenghuaD5DataReqVO.java @@ -0,0 +1,41 @@ +package com.viewsh.module.iot.gateway.codec.henghua.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +/** + * 恒华 D5 客流摄像机 — type=1 拌线统计数据上报请求 + */ +@Data +public class HenghuaD5DataReqVO { + + /** + * 时间日期字符串,格式 "YYYY-MM-DD HH:MM:SS" + */ + @JsonProperty("DataDateTime") + private String dataDateTime; + + /** + * 进入人数(累计值) + */ + @JsonProperty("InNum") + private Integer inNum; + + /** + * 离开人数(累计值) + */ + @JsonProperty("OutNum") + private Integer outNum; + + /** + * 经过人数(累计值) + */ + @JsonProperty("PassNum") + private Integer passNum; + + /** + * Unix 时间戳(秒) + */ + private Long timeStamp; + +} diff --git a/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/protocol/http/router/IotHttpUpstreamHandler.java b/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/protocol/http/router/IotHttpUpstreamHandler.java index 6d4a3b1f..85cdb299 100644 --- a/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/protocol/http/router/IotHttpUpstreamHandler.java +++ b/viewsh-module-iot/viewsh-module-iot-gateway/src/main/java/com/viewsh/module/iot/gateway/protocol/http/router/IotHttpUpstreamHandler.java @@ -8,6 +8,7 @@ import cn.hutool.extra.spring.SpringUtil; import com.viewsh.framework.common.pojo.CommonResult; import com.viewsh.module.iot.core.biz.dto.IotDeviceRespDTO; import com.viewsh.module.iot.core.mq.message.IotDeviceMessage; +import com.viewsh.module.iot.gateway.codec.henghua.IotHenghuaD5Codec; import com.viewsh.module.iot.gateway.codec.people.IotPeopleCounterCodec; import com.viewsh.module.iot.gateway.codec.people.dto.PeopleCounterUploadResp; import com.viewsh.module.iot.gateway.protocol.http.IotHttpUpstreamProtocol; @@ -49,8 +50,9 @@ public class IotHttpUpstreamHandler extends IotHttpAbstractHandler { // 2. 获取设备信息(提前获取,避免重复调用) IotDeviceRespDTO device = deviceService.getDeviceFromCache(productKey, deviceName); - boolean isPeopleCounter = device != null - && StrUtil.equals(device.getCodecType(), IotPeopleCounterCodec.TYPE); + String codecType = device != null ? device.getCodecType() : null; + boolean isPeopleCounter = StrUtil.equals(codecType, IotPeopleCounterCodec.TYPE); + boolean isHenghuaD5 = StrUtil.equals(codecType, IotHenghuaD5Codec.TYPE); // 3. 解码设备消息 byte[] bytes = context.body().buffer().getBytes(); @@ -62,6 +64,9 @@ public class IotHttpUpstreamHandler extends IotHttpAbstractHandler { if (isPeopleCounter) { return writePeopleCounterResponse(context); } + if (isHenghuaD5) { + return writeHenghuaD5Response(context); + } return CommonResult.error(400, "消息解码失败"); } @@ -69,7 +74,7 @@ public class IotHttpUpstreamHandler extends IotHttpAbstractHandler { // 5. 发送消息到消息队列 // 注意:HTTP 是短连接协议,响应直接通过 HTTP 返回,不需要记录回复消息 - // 所以 serverId 传 null,让系统不记录回复消��(参见 IotDeviceMessageServiceImpl.handleUpstreamDeviceMessage 第186行) + // 所以 serverId 传 null,让系统不记录回复消息 deviceMessageService.sendDeviceMessage(message, productKey, deviceName, null); @@ -77,6 +82,9 @@ public class IotHttpUpstreamHandler extends IotHttpAbstractHandler { if (isPeopleCounter) { return writePeopleCounterResponse(context); } + if (isHenghuaD5) { + return writeHenghuaD5Response(context); + } return CommonResult.success(MapUtil.of("messageId", message.getId())); } @@ -89,4 +97,12 @@ public class IotHttpUpstreamHandler extends IotHttpAbstractHandler { return null; // 返回 null 表示响应已写入 } + /** + * 写入恒华D5客流摄像机响应 + */ + private CommonResult writeHenghuaD5Response(RoutingContext context) { + writeResponse(context, MapUtil.builder("code", 0).put("msg", "success").build()); + return null; + } + } From 1ca472ea936ab3452952c0471a6c762381997cae Mon Sep 17 00:00:00 2001 From: lzh Date: Thu, 9 Apr 2026 13:59:20 +0800 Subject: [PATCH 16/16] =?UTF-8?q?feat(iot):=20=E5=AE=A2=E6=B5=81=E8=AE=A1?= =?UTF-8?q?=E6=95=B0=E5=99=A8=E6=94=AF=E6=8C=81=E7=B4=AF=E8=AE=A1=E5=80=BC?= =?UTF-8?q?=E4=B8=8A=E6=8A=A5=E6=A8=A1=E5=BC=8F=EF=BC=88CUMULATIVE?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TrafficThresholdConfig 新增 reportMode 字段,支持 INCREMENTAL(默认)和 CUMULATIVE 两种模式。 累计值设备通过 Redis 存储上次值自动算差值,处理首次上报跳过和设备重启归零场景。 现有增量设备无需改配置,行为完全兼容。 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../clean/TrafficThresholdConfig.java | 21 ++++ .../redis/clean/TrafficCounterRedisDAO.java | 52 ++++++++++ .../TrafficThresholdRuleProcessor.java | 95 ++++++++++++++----- 3 files changed, 146 insertions(+), 22 deletions(-) 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 index cb840c99..b2dcd484 100644 --- 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 @@ -12,6 +12,27 @@ import lombok.Data; @Data public class TrafficThresholdConfig { + /** + * 上报模式:INCREMENTAL / CUMULATIVE + */ + public static final String REPORT_MODE_INCREMENTAL = "INCREMENTAL"; + public static final String REPORT_MODE_CUMULATIVE = "CUMULATIVE"; + + /** + * 设备上报模式 + *

+ * INCREMENTAL(默认):设备上报增量值,直接使用 + * CUMULATIVE:设备上报累计值,需计算差值得到增量 + */ + private String reportMode; + + /** + * 是否累计值模式 + */ + public boolean isCumulative() { + return REPORT_MODE_CUMULATIVE.equalsIgnoreCase(reportMode); + } + /** * 触发阈值 *

diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/redis/clean/TrafficCounterRedisDAO.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/redis/clean/TrafficCounterRedisDAO.java index 0132edfa..6cb4939d 100644 --- a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/redis/clean/TrafficCounterRedisDAO.java +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/dal/redis/clean/TrafficCounterRedisDAO.java @@ -244,6 +244,58 @@ public class TrafficCounterRedisDAO { return null; } + // ==================== 累计值设备上次值存取 ==================== + + /** + * 累计值设备上次上报值 Key 模式 + *

+ * 格式:iot:clean:traffic:lastvalue:{deviceId} + * Hash Field:people_in / people_out + */ + private static final String LAST_VALUE_KEY_PATTERN = "iot:clean:traffic:lastvalue:%s"; + + /** + * 累计值 TTL(秒)- 7 天,跨天不丢失,设备离线数天后仍能衔接 + */ + private static final int LAST_VALUE_TTL_SECONDS = 604800; + + /** + * 获取设备上次上报的累计值 + * + * @param deviceId 设备ID + * @param identifier 属性标识符(people_in / people_out) + * @return 上次累计值,首次上报时返回 null + */ + public Long getLastCumulativeValue(Long deviceId, String identifier) { + String key = String.format(LAST_VALUE_KEY_PATTERN, deviceId); + Object value = stringRedisTemplate.opsForHash().get(key, identifier); + if (value == null) { + return null; + } + try { + return Long.parseLong(value.toString()); + } catch (NumberFormatException e) { + return null; + } + } + + /** + * 更新设备上次上报的累计值 + * + * @param deviceId 设备ID + * @param identifier 属性标识符(people_in / people_out) + * @param value 当前累计值 + */ + public void setLastCumulativeValue(Long deviceId, String identifier, long value) { + String key = String.format(LAST_VALUE_KEY_PATTERN, deviceId); + stringRedisTemplate.opsForHash().put(key, identifier, String.valueOf(value)); + // 仅在 key 无 TTL 时设置,避免高频上报场景下每次都执行 EXPIRE + Long ttl = stringRedisTemplate.getExpire(key, TimeUnit.SECONDS); + if (ttl == null || ttl == -1) { + stringRedisTemplate.expire(key, LAST_VALUE_TTL_SECONDS, TimeUnit.SECONDS); + } + } + // ==================== 私有方法 ==================== private static String formatThresholdKey(Long deviceId, Long areaId) { 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 52e90107..85b3a7ba 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 @@ -50,12 +50,13 @@ public class TrafficThresholdRuleProcessor { * - people_in:累加到当日统计 + 阈值计数器(需配置) * - people_out:累加到当日统计 *

- * 当日累积统计(用于报表)与工单触发(需配置)解耦: - * 即使设备未配置工单触发规则,统计数据也会正常采集。 + * 支持两种上报模式(通过 configData.trafficThreshold.reportMode 配置): + * - INCREMENTAL(默认):上报值直接作为增量 + * - CUMULATIVE:上报值为累计值,自动计算差值得到增量 * * @param deviceId 设备ID * @param identifier 属性标识符(people_in 或 people_out) - * @param propertyValue 属性值(周期内增量) + * @param propertyValue 属性值(增量或累计值,取决于 reportMode) */ public void processPropertyChange(Long deviceId, String identifier, Object propertyValue) { // 1. 校验属性类型 @@ -66,15 +67,28 @@ public class TrafficThresholdRuleProcessor { log.debug("[TrafficThreshold] 收到客流属性:deviceId={}, identifier={}, value={}", deviceId, identifier, propertyValue); - // 2. 解析增量值 - Long increment = parseTrafficCount(propertyValue); - if (increment == null || increment <= 0) { - log.debug("[TrafficThreshold] 增量值无效:deviceId={}, identifier={}, value={}", - deviceId, identifier, propertyValue); + // 2. 解析原始值 + Long rawValue = parseTrafficCount(propertyValue); + if (rawValue == null || rawValue <= 0) { return; } - // 3. 无条件累加到当日统计(统计与工单触发解耦) + // 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); @@ -84,21 +98,11 @@ public class TrafficThresholdRuleProcessor { log.debug("[TrafficThreshold] 当日统计累加:deviceId={}, identifier={}, increment={}", deviceId, identifier, increment); - // 4. 以下为工单触发逻辑,需要设备配置支持 + // 6. 以下为工单触发逻辑,仅 people_in 参与 if (!"people_in".equals(identifier)) { - return; // people_out 不参与阈值判定 - } - - CleanOrderIntegrationConfigService.AreaDeviceConfigWrapper configWrapper = - getConfigWrapper(deviceId); - if (configWrapper == null || configWrapper.getConfig() == null) { - log.debug("[TrafficThreshold] 设备无工单触发配置:deviceId={}", deviceId); return; } - - TrafficThresholdConfig thresholdConfig = configWrapper.getConfig().getTrafficThreshold(); - if (thresholdConfig == null || !thresholdConfig.getAutoCreateOrder()) { - log.debug("[TrafficThreshold] 未启用客流阈值触发:deviceId={}", deviceId); + if (thresholdConfig == null || !Boolean.TRUE.equals(thresholdConfig.getAutoCreateOrder())) { return; } @@ -106,10 +110,57 @@ public class TrafficThresholdRuleProcessor { 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, + private void handlePeopleIn(Long deviceId, Long areaId, long increment, LocalDate today, TrafficThresholdConfig thresholdConfig, CleanOrderIntegrationConfigService.AreaDeviceConfigWrapper configWrapper) { // 1. 原子累加到阈值计数器,返回累积值(当日统计已在 processPropertyChange 中完成)