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:
lzh
2026-04-05 15:26:14 +08:00
parent 368fa90156
commit 9ffaac5c91
5 changed files with 141 additions and 112 deletions

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -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"));
}
}

View File

@@ -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;
}

View File

@@ -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));
}
// ==================== 实时位置 ====================