From 9ffaac5c91db070e32558c9967e23e50e0d37617 Mon Sep 17 00:00:00 2001 From: lzh Date: Sun, 5 Apr 2026 15:26:14 +0800 Subject: [PATCH] =?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 ca011c2..5cb81d0 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 fa86141..6b1e85c 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 b7f06ce..07ee974 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 1f651fc..4d4a5bb 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 28f15e1..2641319 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)); } // ==================== 实时位置 ====================