feat(ops): 新增轨迹统计接口 summary/hourly-trend/area-stay-stats
- summary: KPI 卡片(作业时长、覆盖区域数、事件数、平均停留) - hourly-trend: 按小时聚合出入趋势 - area-stay-stats: 区域停留分布(含 fullAreaName,按时长降序) - deviceId 可选,不传则汇总全部设备 - selectByDateAndDevice 加 LIMIT 5000 安全上限 - 删除无调用方的 selectTimeline 方法 - enrichWithAreaInfo 改用 buildPaths 批量构建路径 Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<TrajectoryRespDTO> 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<HourlyTrendDTO> getHourlyTrend(TrajectoryStatsReqDTO req);
|
||||
|
||||
/**
|
||||
* 查询区域停留分布(按总停留时长降序)
|
||||
*
|
||||
* @param req 查询条件(date 必填,deviceId 可选)
|
||||
*/
|
||||
List<AreaStayStatsDTO> getAreaStayStats(TrajectoryStatsReqDTO req);
|
||||
|
||||
}
|
||||
|
||||
@@ -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<TrajectoryRespDTO> getTimeline(Long deviceId, LocalDate date) {
|
||||
List<OpsDeviceTrajectoryDO> list = trajectoryMapper.selectTimeline(deviceId, date);
|
||||
List<OpsDeviceTrajectoryDO> list = trajectoryMapper.selectByDateAndDevice(date, deviceId);
|
||||
List<TrajectoryRespDTO> result = BeanUtils.toBean(list, TrajectoryRespDTO.class);
|
||||
enrichWithAreaInfo(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public TrajectorySummaryDTO getSummary(Long deviceId, LocalDate date) {
|
||||
List<OpsDeviceTrajectoryDO> list = trajectoryMapper.selectTimeline(deviceId, date);
|
||||
public TrajectorySummaryDTO getSummary(TrajectoryStatsReqDTO req) {
|
||||
List<OpsDeviceTrajectoryDO> 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<Integer> 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<HourlyTrendDTO> getHourlyTrend(TrajectoryStatsReqDTO req) {
|
||||
List<OpsDeviceTrajectoryDO> list = trajectoryMapper.selectByDateAndDevice(req.getDate(), req.getDeviceId());
|
||||
if (list.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
// 按小时聚合进入/离开次数
|
||||
Map<Integer, long[]> 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<AreaStayStatsDTO> getAreaStayStats(TrajectoryStatsReqDTO req) {
|
||||
List<OpsDeviceTrajectoryDO> list = trajectoryMapper.selectByDateAndDevice(req.getDate(), req.getDeviceId());
|
||||
if (list.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
// 按 areaId 聚合
|
||||
Map<Long, long[]> areaStatsMap = new LinkedHashMap<>();
|
||||
Map<Long, String> 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<OpsBusAreaDO> areas = areaMapper.selectBatchIds(areaStatsMap.keySet());
|
||||
Map<Long, OpsBusAreaDO> areaMap = areas.stream()
|
||||
.collect(Collectors.toMap(OpsBusAreaDO::getId, a -> a, (a, b) -> a));
|
||||
Map<Long, String> 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<TrajectoryRespDTO> list) {
|
||||
if (list == null || list.isEmpty()) {
|
||||
@@ -203,39 +269,11 @@ public class DeviceTrajectoryServiceImpl implements DeviceTrajectoryService {
|
||||
return;
|
||||
}
|
||||
|
||||
// 批量查询区域
|
||||
// 批量查询区域 + 一次性构建 fullAreaName
|
||||
List<OpsBusAreaDO> areas = areaMapper.selectBatchIds(areaIds);
|
||||
Map<Long, OpsBusAreaDO> areaMap = areas.stream()
|
||||
.collect(Collectors.toMap(OpsBusAreaDO::getId, a -> a, (a, b) -> a));
|
||||
|
||||
// 收集需要查询的父级 ID(楼栋信息)
|
||||
Set<Long> 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<Long, OpsBusAreaDO> allAreaMap = new HashMap<>(areaMap);
|
||||
if (!parentIds.isEmpty()) {
|
||||
List<OpsBusAreaDO> parentAreas = areaMapper.selectBatchIds(parentIds);
|
||||
for (OpsBusAreaDO pa : parentAreas) {
|
||||
allAreaMap.put(pa.getId(), pa);
|
||||
}
|
||||
}
|
||||
Map<Long, String> 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<Long, OpsBusAreaDO> 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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user