feat(ops): 新增客流统计后端接口(区域汇总查询+缓存优化)

- 新增 OpsTrafficController 客流统计独立 Controller(/ops/traffic/*)
- 新增区域汇总接口:getAreaTrafficRealtime/getAreaTrafficTrend(多区域ID聚合)
- TrafficRealtimeRespVO 新增 yesterdayHourlyTrend 和 message 字段
- DeviceTrafficRealtimeRespVO 新增 yesterdayHourlyTrend 字段
- 区域接口添加 @Cacheable 5分钟 Redis 缓存
- loadAreaNameMap 添加本地缓存(5分钟TTL)避免重复全表扫描
- areaIds 参数双层限制 200 上限防止 DoS

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
lzh
2026-02-26 16:53:08 +08:00
parent edaa75b838
commit 6cb784a2d8
6 changed files with 608 additions and 4 deletions

View File

@@ -0,0 +1,48 @@
package com.viewsh.module.ops.controller.admin.workorder.vo.statistics;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 单设备/区域维度的实时客流 Response VO
*
* @author lzh
*/
@Schema(description = "管理后台 - 单设备/区域维度实时客流 Response VO")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class DeviceTrafficRealtimeRespVO {
@Schema(description = "设备ID")
private Long deviceId;
@Schema(description = "设备名称")
private String deviceName;
@Schema(description = "区域ID")
private Long areaId;
@Schema(description = "区域名称")
private String areaName;
@Schema(description = "今日进入人数")
private Long todayIn;
@Schema(description = "今日离开人数")
private Long todayOut;
@Schema(description = "当前在场人数")
private Long currentOccupancy;
@Schema(description = "今日小时趋势")
private TrafficRealtimeRespVO.HourlyTrend hourlyTrend;
@Schema(description = "昨日小时趋势")
private TrafficRealtimeRespVO.HourlyTrend yesterdayHourlyTrend;
}

View File

@@ -0,0 +1,47 @@
package com.viewsh.module.ops.controller.admin.workorder.vo.statistics;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* 单设备/区域维度的客流趋势 Response VO
*
* @author lzh
*/
@Schema(description = "管理后台 - 单设备/区域维度客流趋势 Response VO")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class DeviceTrafficTrendRespVO {
@Schema(description = "设备ID")
private Long deviceId;
@Schema(description = "区域ID")
private Long areaId;
@Schema(description = "日期列表")
private List<String> dates;
@Schema(description = "每日进入人数")
private List<Long> inData;
@Schema(description = "每日离开人数")
private List<Long> outData;
@Schema(description = "每日净增人数")
private List<Long> netData;
@Schema(description = "总进入人数")
private Long totalIn;
@Schema(description = "总离开人数")
private Long totalOut;
}

View File

@@ -27,9 +27,15 @@ public class TrafficRealtimeRespVO {
@Schema(description = "区域客流明细")
private List<AreaTrafficItem> areas;
@Schema(description = "小时趋势")
@Schema(description = "今日小时趋势")
private HourlyTrend hourlyTrend;
@Schema(description = "昨日小时趋势")
private HourlyTrend yesterdayHourlyTrend;
@Schema(description = "提示信息,如区域暂未配置客流设备")
private String message;
@Data
@Builder
@NoArgsConstructor

View File

@@ -1,11 +1,14 @@
package com.viewsh.module.ops.service.statistics;
import com.viewsh.module.ops.controller.admin.workorder.vo.statistics.DashboardStatsRespVO;
import com.viewsh.module.ops.controller.admin.workorder.vo.statistics.DeviceTrafficRealtimeRespVO;
import com.viewsh.module.ops.controller.admin.workorder.vo.statistics.DeviceTrafficTrendRespVO;
import com.viewsh.module.ops.controller.admin.workorder.vo.statistics.TrafficRealtimeRespVO;
import com.viewsh.module.ops.controller.admin.workorder.vo.statistics.TrafficTrendRespVO;
import com.viewsh.module.ops.controller.admin.workorder.vo.statistics.WorkspaceStatsRespVO;
import java.time.LocalDate;
import java.util.List;
/**
* 工单统计服务接口
@@ -45,4 +48,58 @@ public interface OpsStatisticsService {
*/
TrafficTrendRespVO getTrafficTrend();
/**
* 按设备查实时客流
*
* @param deviceId 设备ID
* @return 单设备实时客流数据
*/
DeviceTrafficRealtimeRespVO getDeviceTrafficRealtime(Long deviceId);
/**
* 按区域查实时客流(返回该区域下每个设备的明细)
*
* @param areaId 区域ID
* @return 区域下各设备的实时客流数据
*/
List<DeviceTrafficRealtimeRespVO> getAreaDeviceTrafficRealtime(Long areaId);
/**
* 按设备查趋势
*
* @param deviceId 设备ID
* @param startDate 开始日期
* @param endDate 结束日期
* @return 单设备客流趋势数据
*/
DeviceTrafficTrendRespVO getDeviceTrafficTrend(Long deviceId, LocalDate startDate, LocalDate endDate);
/**
* 按区域查趋势(汇总)
*
* @param areaId 区域ID
* @param startDate 开始日期
* @param endDate 结束日期
* @return 区域客流趋势汇总数据
*/
DeviceTrafficTrendRespVO getAreaTrafficTrend(Long areaId, LocalDate startDate, LocalDate endDate);
/**
* 按区域ID列表查实时客流汇总
*
* @param areaIds 区域ID列表
* @return 汇总的实时客流数据
*/
TrafficRealtimeRespVO getAreaTrafficRealtime(List<Long> areaIds);
/**
* 按区域ID列表查趋势汇总
*
* @param areaIds 区域ID列表
* @param startDate 开始日期
* @param endDate 结束日期
* @return 区域客流趋势汇总数据
*/
DeviceTrafficTrendRespVO getAreaTrafficTrend(List<Long> areaIds, LocalDate startDate, LocalDate endDate);
}

View File

@@ -4,6 +4,8 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.viewsh.framework.mybatis.core.query.LambdaQueryWrapperX;
import com.viewsh.module.ops.controller.admin.workorder.vo.statistics.*;
import com.viewsh.module.ops.controller.admin.workorder.vo.statistics.DashboardStatsRespVO.*;
import com.viewsh.module.ops.controller.admin.workorder.vo.statistics.DeviceTrafficRealtimeRespVO;
import com.viewsh.module.ops.controller.admin.workorder.vo.statistics.DeviceTrafficTrendRespVO;
import com.viewsh.module.ops.controller.admin.workorder.vo.statistics.TrafficRealtimeRespVO.AreaTrafficItem;
import com.viewsh.module.ops.controller.admin.workorder.vo.statistics.TrafficRealtimeRespVO.HourlyTrend;
import com.viewsh.module.ops.controller.admin.workorder.vo.statistics.WorkspaceStatsRespVO.UrgentTaskItem;
@@ -155,6 +157,14 @@ public class OpsStatisticsServiceImpl implements OpsStatisticsService {
.ge(OpsTrafficStatisticsDO::getStatHour, todayStart)
.lt(OpsTrafficStatisticsDO::getStatHour, todayEnd));
// 查询昨日数据(用于昨日小时趋势对比)
LocalDate yesterday = today.minusDays(1);
LocalDateTime yesterdayStart = yesterday.atStartOfDay();
List<OpsTrafficStatisticsDO> yesterdayStats = trafficStatisticsMapper.selectList(
new LambdaQueryWrapperX<OpsTrafficStatisticsDO>()
.ge(OpsTrafficStatisticsDO::getStatHour, yesterdayStart)
.lt(OpsTrafficStatisticsDO::getStatHour, todayStart));
// 加载区域名称映射
Map<Long, String> areaNameMap = loadAreaNameMap();
@@ -186,8 +196,9 @@ public class OpsStatisticsServiceImpl implements OpsStatisticsService {
.build());
}
// 小时趋势
// 小时趋势(今日 + 昨日)
HourlyTrend hourlyTrend = buildTrafficHourlyTrend(todayStats);
HourlyTrend yesterdayHourlyTrend = buildTrafficHourlyTrend(yesterdayStats);
return TrafficRealtimeRespVO.builder()
.totalIn(totalIn)
@@ -195,6 +206,7 @@ public class OpsStatisticsServiceImpl implements OpsStatisticsService {
.currentOccupancy(Math.max(0, totalIn - totalOut))
.areas(areas)
.hourlyTrend(hourlyTrend)
.yesterdayHourlyTrend(yesterdayHourlyTrend)
.build();
}
@@ -364,6 +376,244 @@ public class OpsStatisticsServiceImpl implements OpsStatisticsService {
.build();
}
@Override
@Cacheable(value = "ops:statistics:device-traffic#5m", key = "#deviceId", unless = "#result == null")
public DeviceTrafficRealtimeRespVO getDeviceTrafficRealtime(Long deviceId) {
LocalDate today = LocalDate.now();
LocalDateTime todayStart = today.atStartOfDay();
LocalDateTime todayEnd = todayStart.plusDays(1);
List<OpsTrafficStatisticsDO> stats = trafficStatisticsMapper.selectList(
new LambdaQueryWrapperX<OpsTrafficStatisticsDO>()
.eq(OpsTrafficStatisticsDO::getDeviceId, deviceId)
.ge(OpsTrafficStatisticsDO::getStatHour, todayStart)
.lt(OpsTrafficStatisticsDO::getStatHour, todayEnd));
// 查询昨日数据
LocalDate yesterday = today.minusDays(1);
LocalDateTime yesterdayStart = yesterday.atStartOfDay();
List<OpsTrafficStatisticsDO> yesterdayStats = trafficStatisticsMapper.selectList(
new LambdaQueryWrapperX<OpsTrafficStatisticsDO>()
.eq(OpsTrafficStatisticsDO::getDeviceId, deviceId)
.ge(OpsTrafficStatisticsDO::getStatHour, yesterdayStart)
.lt(OpsTrafficStatisticsDO::getStatHour, todayStart));
long totalIn = stats.stream().mapToLong(s -> s.getPeopleIn() != null ? s.getPeopleIn() : 0).sum();
long totalOut = stats.stream().mapToLong(s -> s.getPeopleOut() != null ? s.getPeopleOut() : 0).sum();
// 获取区域信息
Long areaId = stats.stream().map(OpsTrafficStatisticsDO::getAreaId).filter(a -> a != null).findFirst().orElse(null);
Map<Long, String> areaNameMap = loadAreaNameMap();
HourlyTrend hourlyTrend = buildTrafficHourlyTrend(stats);
HourlyTrend yesterdayHourlyTrend = buildTrafficHourlyTrend(yesterdayStats);
return DeviceTrafficRealtimeRespVO.builder()
.deviceId(deviceId)
.areaId(areaId)
.areaName(areaId != null ? areaNameMap.getOrDefault(areaId, "未知区域") : null)
.todayIn(totalIn)
.todayOut(totalOut)
.currentOccupancy(Math.max(0, totalIn - totalOut))
.hourlyTrend(hourlyTrend)
.yesterdayHourlyTrend(yesterdayHourlyTrend)
.build();
}
@Override
@Cacheable(value = "ops:statistics:area-device-traffic#5m", key = "#areaId", unless = "#result == null")
public List<DeviceTrafficRealtimeRespVO> getAreaDeviceTrafficRealtime(Long areaId) {
LocalDate today = LocalDate.now();
LocalDateTime todayStart = today.atStartOfDay();
LocalDateTime todayEnd = todayStart.plusDays(1);
List<OpsTrafficStatisticsDO> stats = trafficStatisticsMapper.selectList(
new LambdaQueryWrapperX<OpsTrafficStatisticsDO>()
.eq(OpsTrafficStatisticsDO::getAreaId, areaId)
.ge(OpsTrafficStatisticsDO::getStatHour, todayStart)
.lt(OpsTrafficStatisticsDO::getStatHour, todayEnd));
// 查询昨日数据
LocalDate yesterday = today.minusDays(1);
LocalDateTime yesterdayStart = yesterday.atStartOfDay();
List<OpsTrafficStatisticsDO> yesterdayStats = trafficStatisticsMapper.selectList(
new LambdaQueryWrapperX<OpsTrafficStatisticsDO>()
.eq(OpsTrafficStatisticsDO::getAreaId, areaId)
.ge(OpsTrafficStatisticsDO::getStatHour, yesterdayStart)
.lt(OpsTrafficStatisticsDO::getStatHour, todayStart));
Map<Long, String> areaNameMap = loadAreaNameMap();
String areaName = areaNameMap.getOrDefault(areaId, "未知区域");
// 按设备分组(今日)
Map<Long, List<OpsTrafficStatisticsDO>> byDevice = stats.stream()
.filter(s -> s.getDeviceId() != null)
.collect(Collectors.groupingBy(OpsTrafficStatisticsDO::getDeviceId));
// 按设备分组(昨日)
Map<Long, List<OpsTrafficStatisticsDO>> yesterdayByDevice = yesterdayStats.stream()
.filter(s -> s.getDeviceId() != null)
.collect(Collectors.groupingBy(OpsTrafficStatisticsDO::getDeviceId));
List<DeviceTrafficRealtimeRespVO> result = new ArrayList<>();
for (Map.Entry<Long, List<OpsTrafficStatisticsDO>> entry : byDevice.entrySet()) {
Long deviceId = entry.getKey();
List<OpsTrafficStatisticsDO> deviceStats = entry.getValue();
long deviceIn = deviceStats.stream().mapToLong(s -> s.getPeopleIn() != null ? s.getPeopleIn() : 0).sum();
long deviceOut = deviceStats.stream().mapToLong(s -> s.getPeopleOut() != null ? s.getPeopleOut() : 0).sum();
HourlyTrend hourlyTrend = buildTrafficHourlyTrend(deviceStats);
HourlyTrend yesterdayHourlyTrend = buildTrafficHourlyTrend(
yesterdayByDevice.getOrDefault(deviceId, List.of()));
result.add(DeviceTrafficRealtimeRespVO.builder()
.deviceId(deviceId)
.areaId(areaId)
.areaName(areaName)
.todayIn(deviceIn)
.todayOut(deviceOut)
.currentOccupancy(Math.max(0, deviceIn - deviceOut))
.hourlyTrend(hourlyTrend)
.yesterdayHourlyTrend(yesterdayHourlyTrend)
.build());
}
return result;
}
@Override
@Cacheable(value = "ops:statistics:device-traffic-trend#5m",
key = "#deviceId + ':' + #startDate + ':' + #endDate", unless = "#result == null")
public DeviceTrafficTrendRespVO getDeviceTrafficTrend(Long deviceId, LocalDate startDate, LocalDate endDate) {
List<OpsTrafficStatisticsDO> stats = trafficStatisticsMapper.selectList(
new LambdaQueryWrapperX<OpsTrafficStatisticsDO>()
.eq(OpsTrafficStatisticsDO::getDeviceId, deviceId)
.ge(OpsTrafficStatisticsDO::getStatHour, startDate.atStartOfDay())
.lt(OpsTrafficStatisticsDO::getStatHour, endDate.plusDays(1).atStartOfDay()));
return buildDeviceTrafficTrendFromStats(stats, deviceId, null, startDate, endDate);
}
@Override
@Cacheable(value = "ops:statistics:area-traffic-trend#5m",
key = "#areaId + ':' + #startDate + ':' + #endDate", unless = "#result == null")
public DeviceTrafficTrendRespVO getAreaTrafficTrend(Long areaId, LocalDate startDate, LocalDate endDate) {
List<OpsTrafficStatisticsDO> stats = trafficStatisticsMapper.selectList(
new LambdaQueryWrapperX<OpsTrafficStatisticsDO>()
.eq(OpsTrafficStatisticsDO::getAreaId, areaId)
.ge(OpsTrafficStatisticsDO::getStatHour, startDate.atStartOfDay())
.lt(OpsTrafficStatisticsDO::getStatHour, endDate.plusDays(1).atStartOfDay()));
return buildDeviceTrafficTrendFromStats(stats, null, areaId, startDate, endDate);
}
@Override
@Cacheable(value = "ops:statistics:area-traffic-realtime#5m",
key = "T(String).join(',', #areaIds.![toString()])", unless = "#result == null")
public TrafficRealtimeRespVO getAreaTrafficRealtime(List<Long> areaIds) {
if (areaIds == null || areaIds.isEmpty()) {
return TrafficRealtimeRespVO.builder()
.totalIn(0L).totalOut(0L).currentOccupancy(0L)
.message("该区域暂未配置客流设备")
.build();
}
// 限制 areaIds 长度,防止 IN 查询过大
if (areaIds.size() > 200) {
areaIds = areaIds.subList(0, 200);
}
LocalDate today = LocalDate.now();
LocalDateTime todayStart = today.atStartOfDay();
LocalDateTime todayEnd = todayStart.plusDays(1);
// 查询今日数据,条件 areaId IN (areaIds)
List<OpsTrafficStatisticsDO> todayStats = trafficStatisticsMapper.selectList(
new LambdaQueryWrapperX<OpsTrafficStatisticsDO>()
.in(OpsTrafficStatisticsDO::getAreaId, areaIds)
.ge(OpsTrafficStatisticsDO::getStatHour, todayStart)
.lt(OpsTrafficStatisticsDO::getStatHour, todayEnd));
// 查询昨日数据
LocalDate yesterday = today.minusDays(1);
LocalDateTime yesterdayStart = yesterday.atStartOfDay();
List<OpsTrafficStatisticsDO> yesterdayStats = trafficStatisticsMapper.selectList(
new LambdaQueryWrapperX<OpsTrafficStatisticsDO>()
.in(OpsTrafficStatisticsDO::getAreaId, areaIds)
.ge(OpsTrafficStatisticsDO::getStatHour, yesterdayStart)
.lt(OpsTrafficStatisticsDO::getStatHour, todayStart));
// 无数据时设置提示信息
if (todayStats.isEmpty() && yesterdayStats.isEmpty()) {
return TrafficRealtimeRespVO.builder()
.totalIn(0L).totalOut(0L).currentOccupancy(0L)
.message("该区域暂未配置客流设备")
.build();
}
// 汇总 totalIn、totalOut
long totalIn = todayStats.stream().mapToLong(s -> s.getPeopleIn() != null ? s.getPeopleIn() : 0).sum();
long totalOut = todayStats.stream().mapToLong(s -> s.getPeopleOut() != null ? s.getPeopleOut() : 0).sum();
// 按区域汇总明细
Map<Long, String> areaNameMap = loadAreaNameMap();
Map<Long, List<OpsTrafficStatisticsDO>> byArea = todayStats.stream()
.filter(s -> s.getAreaId() != null)
.collect(Collectors.groupingBy(OpsTrafficStatisticsDO::getAreaId));
List<AreaTrafficItem> areas = new ArrayList<>();
for (Map.Entry<Long, List<OpsTrafficStatisticsDO>> entry : byArea.entrySet()) {
Long areaId = entry.getKey();
List<OpsTrafficStatisticsDO> stats = entry.getValue();
long areaIn = stats.stream().mapToLong(s -> s.getPeopleIn() != null ? s.getPeopleIn() : 0).sum();
long areaOut = stats.stream().mapToLong(s -> s.getPeopleOut() != null ? s.getPeopleOut() : 0).sum();
int deviceCount = (int) stats.stream().map(OpsTrafficStatisticsDO::getDeviceId).distinct().count();
areas.add(AreaTrafficItem.builder()
.areaId(areaId)
.areaName(areaNameMap.getOrDefault(areaId, "未知区域"))
.todayIn(areaIn)
.todayOut(areaOut)
.currentOccupancy(Math.max(0, areaIn - areaOut))
.deviceCount(deviceCount)
.build());
}
// 小时趋势
HourlyTrend hourlyTrend = buildTrafficHourlyTrend(todayStats);
HourlyTrend yesterdayHourlyTrend = buildTrafficHourlyTrend(yesterdayStats);
return TrafficRealtimeRespVO.builder()
.totalIn(totalIn)
.totalOut(totalOut)
.currentOccupancy(Math.max(0, totalIn - totalOut))
.areas(areas)
.hourlyTrend(hourlyTrend)
.yesterdayHourlyTrend(yesterdayHourlyTrend)
.build();
}
@Override
@Cacheable(value = "ops:statistics:area-traffic-trend-multi#5m",
key = "T(String).join(',', #areaIds.![toString()]) + ':' + #startDate + ':' + #endDate",
unless = "#result == null")
public DeviceTrafficTrendRespVO getAreaTrafficTrend(List<Long> areaIds, LocalDate startDate, LocalDate endDate) {
if (areaIds == null || areaIds.isEmpty()) {
return DeviceTrafficTrendRespVO.builder().build();
}
// 限制 areaIds 长度
if (areaIds.size() > 200) {
areaIds = areaIds.subList(0, 200);
}
List<OpsTrafficStatisticsDO> stats = trafficStatisticsMapper.selectList(
new LambdaQueryWrapperX<OpsTrafficStatisticsDO>()
.in(OpsTrafficStatisticsDO::getAreaId, areaIds)
.ge(OpsTrafficStatisticsDO::getStatHour, startDate.atStartOfDay())
.lt(OpsTrafficStatisticsDO::getStatHour, endDate.plusDays(1).atStartOfDay()));
return buildDeviceTrafficTrendFromStats(stats, null, null, startDate, endDate);
}
// ==================== 私有方法 ====================
private Integer countByStatusAndType(String status, String orderType) {
@@ -694,12 +944,72 @@ public class OpsStatisticsServiceImpl implements OpsStatisticsService {
}
/**
* 加载区域名称映射
* 构建设备/区域维度的客流趋势数据
*/
private DeviceTrafficTrendRespVO buildDeviceTrafficTrendFromStats(
List<OpsTrafficStatisticsDO> stats, Long deviceId, Long areaId,
LocalDate startDate, LocalDate endDate) {
Map<LocalDate, Long> dailyInMap = new HashMap<>();
Map<LocalDate, Long> dailyOutMap = new HashMap<>();
for (OpsTrafficStatisticsDO stat : stats) {
if (stat.getStatHour() != null) {
LocalDate date = stat.getStatHour().toLocalDate();
long peopleIn = stat.getPeopleIn() != null ? stat.getPeopleIn() : 0;
long peopleOut = stat.getPeopleOut() != null ? stat.getPeopleOut() : 0;
dailyInMap.merge(date, peopleIn, Long::sum);
dailyOutMap.merge(date, peopleOut, Long::sum);
}
}
List<String> dates = new ArrayList<>();
List<Long> inData = new ArrayList<>();
List<Long> outData = new ArrayList<>();
List<Long> netData = new ArrayList<>();
long totalIn = 0;
long totalOut = 0;
for (LocalDate d = startDate; !d.isAfter(endDate); d = d.plusDays(1)) {
dates.add(d.format(DATE_FORMATTER));
long dayIn = dailyInMap.getOrDefault(d, 0L);
long dayOut = dailyOutMap.getOrDefault(d, 0L);
inData.add(dayIn);
outData.add(dayOut);
netData.add(dayIn - dayOut);
totalIn += dayIn;
totalOut += dayOut;
}
return DeviceTrafficTrendRespVO.builder()
.deviceId(deviceId)
.areaId(areaId)
.dates(dates)
.inData(inData)
.outData(outData)
.netData(netData)
.totalIn(totalIn)
.totalOut(totalOut)
.build();
}
/**
* 加载区域名称映射带本地缓存5分钟过期
*/
private volatile Map<Long, String> areaNameMapCache;
private volatile long areaNameMapCacheTime;
private static final long AREA_NAME_CACHE_TTL = 5 * 60 * 1000L; // 5分钟
private Map<Long, String> loadAreaNameMap() {
long now = System.currentTimeMillis();
if (areaNameMapCache != null && (now - areaNameMapCacheTime) < AREA_NAME_CACHE_TTL) {
return areaNameMapCache;
}
List<OpsBusAreaDO> areas = opsBusAreaMapper.selectList(new LambdaQueryWrapper<>());
return areas.stream()
Map<Long, String> map = areas.stream()
.collect(Collectors.toMap(OpsBusAreaDO::getId, OpsBusAreaDO::getAreaName, (a, b) -> a));
areaNameMapCache = map;
areaNameMapCacheTime = now;
return map;
}
}