diff --git a/sql/mysql/ops_device_trajectory.sql b/sql/mysql/ops_device_trajectory.sql new file mode 100644 index 0000000..b080b7b --- /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 0000000..a1363db --- /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 0000000..6499c88 --- /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 0000000..26ada6f --- /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 0000000..76fb87c --- /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 0000000..ca011c2 --- /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 0000000..fa86141 --- /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 0000000..6a29f7b --- /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 0000000..f5c65db --- /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 0000000..b7f06ce --- /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)); + } + +}