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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,16 +68,19 @@ public interface OpsDeviceTrajectoryMapper extends BaseMapperX<OpsDeviceTrajecto
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询某设备某天的轨迹时间线(不分页,按进入时间升序)
|
||||
* 查询某天的轨迹记录(deviceId 可选,为 null 则查全部设备)
|
||||
* <p>
|
||||
* 安全上限 5000 条,防止全量加载过多数据到内存
|
||||
*/
|
||||
default List<OpsDeviceTrajectoryDO> selectTimeline(Long deviceId, LocalDate date) {
|
||||
default List<OpsDeviceTrajectoryDO> selectByDateAndDevice(LocalDate date, Long deviceId) {
|
||||
LocalDateTime start = date.atStartOfDay();
|
||||
LocalDateTime end = date.plusDays(1).atStartOfDay();
|
||||
return selectList(new LambdaQueryWrapperX<OpsDeviceTrajectoryDO>()
|
||||
.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"));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
|
||||
@@ -126,10 +126,24 @@ public class TrajectoryController {
|
||||
}
|
||||
|
||||
@GetMapping("/summary")
|
||||
@Operation(summary = "获得设备某天的轨迹统计摘要")
|
||||
@Operation(summary = "获得轨迹统计摘要(KPI 卡片)")
|
||||
@PreAuthorize("@ss.hasPermission('ops:trajectory:query')")
|
||||
public CommonResult<TrajectorySummaryDTO> getSummary(@Valid TrajectoryTimelineReqDTO req) {
|
||||
return success(trajectoryService.getSummary(req.getDeviceId(), req.getDate()));
|
||||
public CommonResult<TrajectorySummaryDTO> getSummary(@Valid TrajectoryStatsReqDTO req) {
|
||||
return success(trajectoryService.getSummary(req));
|
||||
}
|
||||
|
||||
@GetMapping("/hourly-trend")
|
||||
@Operation(summary = "获得时段出入趋势(按小时聚合)")
|
||||
@PreAuthorize("@ss.hasPermission('ops:trajectory:query')")
|
||||
public CommonResult<List<HourlyTrendDTO>> getHourlyTrend(@Valid TrajectoryStatsReqDTO req) {
|
||||
return success(trajectoryService.getHourlyTrend(req));
|
||||
}
|
||||
|
||||
@GetMapping("/area-stay-stats")
|
||||
@Operation(summary = "获得区域停留分布")
|
||||
@PreAuthorize("@ss.hasPermission('ops:trajectory:query')")
|
||||
public CommonResult<List<AreaStayStatsDTO>> getAreaStayStats(@Valid TrajectoryStatsReqDTO req) {
|
||||
return success(trajectoryService.getAreaStayStats(req));
|
||||
}
|
||||
|
||||
// ==================== 实时位置 ====================
|
||||
|
||||
Reference in New Issue
Block a user