From 6cb784a2d8bda551a3d153179037630a77519b4d Mon Sep 17 00:00:00 2001 From: lzh Date: Thu, 26 Feb 2026 16:53:08 +0800 Subject: [PATCH] =?UTF-8?q?feat(ops):=20=E6=96=B0=E5=A2=9E=E5=AE=A2?= =?UTF-8?q?=E6=B5=81=E7=BB=9F=E8=AE=A1=E5=90=8E=E7=AB=AF=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=EF=BC=88=E5=8C=BA=E5=9F=9F=E6=B1=87=E6=80=BB=E6=9F=A5=E8=AF=A2?= =?UTF-8?q?+=E7=BC=93=E5=AD=98=E4=BC=98=E5=8C=96=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 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 --- .../DeviceTrafficRealtimeRespVO.java | 48 +++ .../statistics/DeviceTrafficTrendRespVO.java | 47 +++ .../vo/statistics/TrafficRealtimeRespVO.java | 8 +- .../statistics/OpsStatisticsService.java | 57 ++++ .../statistics/OpsStatisticsServiceImpl.java | 316 +++++++++++++++++- .../admin/traffic/OpsTrafficController.java | 136 ++++++++ 6 files changed, 608 insertions(+), 4 deletions(-) create mode 100644 viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/controller/admin/workorder/vo/statistics/DeviceTrafficRealtimeRespVO.java create mode 100644 viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/controller/admin/workorder/vo/statistics/DeviceTrafficTrendRespVO.java create mode 100644 viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/traffic/OpsTrafficController.java diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/controller/admin/workorder/vo/statistics/DeviceTrafficRealtimeRespVO.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/controller/admin/workorder/vo/statistics/DeviceTrafficRealtimeRespVO.java new file mode 100644 index 0000000..ea58b02 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/controller/admin/workorder/vo/statistics/DeviceTrafficRealtimeRespVO.java @@ -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; + +} diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/controller/admin/workorder/vo/statistics/DeviceTrafficTrendRespVO.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/controller/admin/workorder/vo/statistics/DeviceTrafficTrendRespVO.java new file mode 100644 index 0000000..c8c0485 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/controller/admin/workorder/vo/statistics/DeviceTrafficTrendRespVO.java @@ -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 dates; + + @Schema(description = "每日进入人数") + private List inData; + + @Schema(description = "每日离开人数") + private List outData; + + @Schema(description = "每日净增人数") + private List netData; + + @Schema(description = "总进入人数") + private Long totalIn; + + @Schema(description = "总离开人数") + private Long totalOut; + +} diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/controller/admin/workorder/vo/statistics/TrafficRealtimeRespVO.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/controller/admin/workorder/vo/statistics/TrafficRealtimeRespVO.java index ccaec2a..6ce4ffb 100644 --- a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/controller/admin/workorder/vo/statistics/TrafficRealtimeRespVO.java +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/controller/admin/workorder/vo/statistics/TrafficRealtimeRespVO.java @@ -27,9 +27,15 @@ public class TrafficRealtimeRespVO { @Schema(description = "区域客流明细") private List areas; - @Schema(description = "小时趋势") + @Schema(description = "今日小时趋势") private HourlyTrend hourlyTrend; + @Schema(description = "昨日小时趋势") + private HourlyTrend yesterdayHourlyTrend; + + @Schema(description = "提示信息,如区域暂未配置客流设备") + private String message; + @Data @Builder @NoArgsConstructor diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/statistics/OpsStatisticsService.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/statistics/OpsStatisticsService.java index 6e2d9cd..290f547 100644 --- a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/statistics/OpsStatisticsService.java +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/statistics/OpsStatisticsService.java @@ -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 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 areaIds); + + /** + * 按区域ID列表查趋势(汇总) + * + * @param areaIds 区域ID列表 + * @param startDate 开始日期 + * @param endDate 结束日期 + * @return 区域客流趋势汇总数据 + */ + DeviceTrafficTrendRespVO getAreaTrafficTrend(List areaIds, LocalDate startDate, LocalDate endDate); + } diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/statistics/OpsStatisticsServiceImpl.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/statistics/OpsStatisticsServiceImpl.java index 2e6b295..c9a0fcf 100644 --- a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/statistics/OpsStatisticsServiceImpl.java +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/statistics/OpsStatisticsServiceImpl.java @@ -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 yesterdayStats = trafficStatisticsMapper.selectList( + new LambdaQueryWrapperX() + .ge(OpsTrafficStatisticsDO::getStatHour, yesterdayStart) + .lt(OpsTrafficStatisticsDO::getStatHour, todayStart)); + // 加载区域名称映射 Map 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 stats = trafficStatisticsMapper.selectList( + new LambdaQueryWrapperX() + .eq(OpsTrafficStatisticsDO::getDeviceId, deviceId) + .ge(OpsTrafficStatisticsDO::getStatHour, todayStart) + .lt(OpsTrafficStatisticsDO::getStatHour, todayEnd)); + + // 查询昨日数据 + LocalDate yesterday = today.minusDays(1); + LocalDateTime yesterdayStart = yesterday.atStartOfDay(); + List yesterdayStats = trafficStatisticsMapper.selectList( + new LambdaQueryWrapperX() + .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 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 getAreaDeviceTrafficRealtime(Long areaId) { + LocalDate today = LocalDate.now(); + LocalDateTime todayStart = today.atStartOfDay(); + LocalDateTime todayEnd = todayStart.plusDays(1); + + List stats = trafficStatisticsMapper.selectList( + new LambdaQueryWrapperX() + .eq(OpsTrafficStatisticsDO::getAreaId, areaId) + .ge(OpsTrafficStatisticsDO::getStatHour, todayStart) + .lt(OpsTrafficStatisticsDO::getStatHour, todayEnd)); + + // 查询昨日数据 + LocalDate yesterday = today.minusDays(1); + LocalDateTime yesterdayStart = yesterday.atStartOfDay(); + List yesterdayStats = trafficStatisticsMapper.selectList( + new LambdaQueryWrapperX() + .eq(OpsTrafficStatisticsDO::getAreaId, areaId) + .ge(OpsTrafficStatisticsDO::getStatHour, yesterdayStart) + .lt(OpsTrafficStatisticsDO::getStatHour, todayStart)); + + Map areaNameMap = loadAreaNameMap(); + String areaName = areaNameMap.getOrDefault(areaId, "未知区域"); + + // 按设备分组(今日) + Map> byDevice = stats.stream() + .filter(s -> s.getDeviceId() != null) + .collect(Collectors.groupingBy(OpsTrafficStatisticsDO::getDeviceId)); + + // 按设备分组(昨日) + Map> yesterdayByDevice = yesterdayStats.stream() + .filter(s -> s.getDeviceId() != null) + .collect(Collectors.groupingBy(OpsTrafficStatisticsDO::getDeviceId)); + + List result = new ArrayList<>(); + for (Map.Entry> entry : byDevice.entrySet()) { + Long deviceId = entry.getKey(); + List 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 stats = trafficStatisticsMapper.selectList( + new LambdaQueryWrapperX() + .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 stats = trafficStatisticsMapper.selectList( + new LambdaQueryWrapperX() + .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 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 todayStats = trafficStatisticsMapper.selectList( + new LambdaQueryWrapperX() + .in(OpsTrafficStatisticsDO::getAreaId, areaIds) + .ge(OpsTrafficStatisticsDO::getStatHour, todayStart) + .lt(OpsTrafficStatisticsDO::getStatHour, todayEnd)); + + // 查询昨日数据 + LocalDate yesterday = today.minusDays(1); + LocalDateTime yesterdayStart = yesterday.atStartOfDay(); + List yesterdayStats = trafficStatisticsMapper.selectList( + new LambdaQueryWrapperX() + .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 areaNameMap = loadAreaNameMap(); + Map> byArea = todayStats.stream() + .filter(s -> s.getAreaId() != null) + .collect(Collectors.groupingBy(OpsTrafficStatisticsDO::getAreaId)); + + List areas = new ArrayList<>(); + for (Map.Entry> entry : byArea.entrySet()) { + Long areaId = entry.getKey(); + List 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 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 stats = trafficStatisticsMapper.selectList( + new LambdaQueryWrapperX() + .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 stats, Long deviceId, Long areaId, + LocalDate startDate, LocalDate endDate) { + Map dailyInMap = new HashMap<>(); + Map 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 dates = new ArrayList<>(); + List inData = new ArrayList<>(); + List outData = new ArrayList<>(); + List 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 areaNameMapCache; + private volatile long areaNameMapCacheTime; + private static final long AREA_NAME_CACHE_TTL = 5 * 60 * 1000L; // 5分钟 + private Map loadAreaNameMap() { + long now = System.currentTimeMillis(); + if (areaNameMapCache != null && (now - areaNameMapCacheTime) < AREA_NAME_CACHE_TTL) { + return areaNameMapCache; + } List areas = opsBusAreaMapper.selectList(new LambdaQueryWrapper<>()); - return areas.stream() + Map map = areas.stream() .collect(Collectors.toMap(OpsBusAreaDO::getId, OpsBusAreaDO::getAreaName, (a, b) -> a)); + areaNameMapCache = map; + areaNameMapCacheTime = now; + return map; } } diff --git a/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/traffic/OpsTrafficController.java b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/traffic/OpsTrafficController.java new file mode 100644 index 0000000..26f539f --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/traffic/OpsTrafficController.java @@ -0,0 +1,136 @@ +package com.viewsh.module.ops.controller.admin.traffic; + +import com.viewsh.framework.common.pojo.CommonResult; +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.service.statistics.OpsStatisticsService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.time.LocalDate; +import java.util.List; + +import static com.viewsh.framework.common.pojo.CommonResult.success; + +/** + * 管理后台 - 客流统计 Controller + * + * @author lzh + */ +@Tag(name = "管理后台 - 客流统计") +@Slf4j +@RestController +@RequestMapping("/ops/traffic") +@Validated +public class OpsTrafficController { + + @Autowired(required = false) + private OpsStatisticsService opsStatisticsService; + + @GetMapping("/realtime") + @Operation(summary = "全局实时客流监测") + @PreAuthorize("@ss.hasPermission('ops:traffic:query')") + public CommonResult getTrafficRealtime() { + if (opsStatisticsService == null) { + log.warn("[getTrafficRealtime] OpsStatisticsService 未注入,返回默认值"); + return success(TrafficRealtimeRespVO.builder().build()); + } + return success(opsStatisticsService.getTrafficRealtime()); + } + + @GetMapping("/trend") + @Operation(summary = "全局近7天客流趋势统计") + @PreAuthorize("@ss.hasPermission('ops:traffic:query')") + public CommonResult getTrafficTrend() { + if (opsStatisticsService == null) { + log.warn("[getTrafficTrend] OpsStatisticsService 未注入,返回默认值"); + return success(TrafficTrendRespVO.builder().build()); + } + return success(opsStatisticsService.getTrafficTrend()); + } + + @GetMapping("/device/realtime") + @Operation(summary = "单设备实时客流") + @Parameter(name = "deviceId", description = "设备ID", required = true) + @PreAuthorize("@ss.hasPermission('ops:traffic:query')") + public CommonResult getDeviceTrafficRealtime( + @RequestParam("deviceId") Long deviceId) { + if (opsStatisticsService == null) { + log.warn("[getDeviceTrafficRealtime] OpsStatisticsService 未注入,返回默认值"); + return success(DeviceTrafficRealtimeRespVO.builder().build()); + } + return success(opsStatisticsService.getDeviceTrafficRealtime(deviceId)); + } + + @GetMapping("/area/realtime") + @Operation(summary = "区域实时客流(汇总)") + @Parameter(name = "areaIds", description = "区域ID列表", required = true) + @PreAuthorize("@ss.hasPermission('ops:traffic:query')") + public CommonResult getAreaTrafficRealtime( + @RequestParam("areaIds") List areaIds) { + if (opsStatisticsService == null) { + log.warn("[getAreaTrafficRealtime] OpsStatisticsService 未注入,返回默认值"); + return success(TrafficRealtimeRespVO.builder().build()); + } + if (areaIds.size() > 200) { + areaIds = areaIds.subList(0, 200); + } + return success(opsStatisticsService.getAreaTrafficRealtime(areaIds)); + } + + @GetMapping("/device/trend") + @Operation(summary = "单设备客流趋势") + @PreAuthorize("@ss.hasPermission('ops:traffic:query')") + public CommonResult getDeviceTrafficTrend( + @RequestParam("deviceId") @Parameter(description = "设备ID", required = true) Long deviceId, + @RequestParam(value = "startDate", required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate startDate, + @RequestParam(value = "endDate", required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate endDate) { + if (opsStatisticsService == null) { + log.warn("[getDeviceTrafficTrend] OpsStatisticsService 未注入,返回默认值"); + return success(DeviceTrafficTrendRespVO.builder().build()); + } + if (startDate == null) { + startDate = LocalDate.now().minusDays(6); + } + if (endDate == null) { + endDate = LocalDate.now(); + } + return success(opsStatisticsService.getDeviceTrafficTrend(deviceId, startDate, endDate)); + } + + @GetMapping("/area/trend") + @Operation(summary = "区域客流趋势(汇总)") + @PreAuthorize("@ss.hasPermission('ops:traffic:query')") + public CommonResult getAreaTrafficTrend( + @RequestParam("areaIds") @Parameter(description = "区域ID列表", required = true) List areaIds, + @RequestParam(value = "startDate", required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate startDate, + @RequestParam(value = "endDate", required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate endDate) { + if (opsStatisticsService == null) { + log.warn("[getAreaTrafficTrend] OpsStatisticsService 未注入,返回默认值"); + return success(DeviceTrafficTrendRespVO.builder().build()); + } + if (areaIds.size() > 200) { + areaIds = areaIds.subList(0, 200); + } + if (startDate == null) { + startDate = LocalDate.now().minusDays(6); + } + if (endDate == null) { + endDate = LocalDate.now(); + } + return success(opsStatisticsService.getAreaTrafficTrend(areaIds, startDate, endDate)); + } + +}