Merge branch 'master' into feat/multi-tenant

# Conflicts:
#	viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/dal/redis/TrafficActiveOrderRedisDAO.java
#	viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/badge/BadgeDeviceStatusServiceImpl.java
#	viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/dispatch/UserDispatchStatusServiceImpl.java
This commit is contained in:
lzh
2026-04-13 14:35:27 +08:00
57 changed files with 3612 additions and 106 deletions

View File

@@ -0,0 +1,104 @@
package com.viewsh.module.ops.dal.dataobject.trajectory;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.viewsh.framework.tenant.core.db.TenantBaseDO;
import lombok.*;
import java.time.LocalDateTime;
/**
* 设备轨迹记录 DO
* <p>
* 记录工牌设备进出各区域的轨迹
* 一条记录表示一次"进入-离开"周期
*
* @author lzh
*/
@TableName("ops_device_trajectory")
@KeySequence("ops_device_trajectory_seq")
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class OpsDeviceTrajectoryDO extends TenantBaseDO {
/**
* 主键
*/
@TableId
private Long id;
/**
* 工牌设备ID
*/
private Long deviceId;
/**
* 设备名称(冗余)
*/
private String deviceName;
/**
* 设备备注名称(冗余)
*/
private String nickname;
/**
* 人员ID预留
*/
private Long personId;
/**
* 人员名称(预留)
*/
private String personName;
/**
* 区域ID
*/
private Long areaId;
/**
* 区域名称(冗余)
*/
private String areaName;
/**
* 匹配的 Beacon MAC
*/
private String beaconMac;
/**
* 进入时间
*/
private LocalDateTime enterTime;
/**
* 离开时间
*/
private LocalDateTime leaveTime;
/**
* 停留时长(秒)
*/
private Integer durationSeconds;
/**
* 离开原因
* <p>
* SIGNAL_LOSS - 信号丢失
* AREA_SWITCH - 切换到其他区域
* DEVICE_OFFLINE - 设备离线
*/
private String leaveReason;
/**
* 进入时 RSSI
*/
private Integer enterRssi;
}

View File

@@ -0,0 +1,86 @@
package com.viewsh.module.ops.dal.mysql.trajectory;
import com.viewsh.framework.common.pojo.PageResult;
import com.viewsh.framework.mybatis.core.mapper.BaseMapperX;
import com.viewsh.framework.mybatis.core.query.LambdaQueryWrapperX;
import com.viewsh.module.ops.dal.dataobject.trajectory.OpsDeviceTrajectoryDO;
import com.viewsh.module.ops.service.trajectory.dto.TrajectoryPageReqDTO;
import org.apache.ibatis.annotations.Mapper;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
/**
* 设备轨迹记录 Mapper
*
* @author lzh
*/
@Mapper
public interface OpsDeviceTrajectoryMapper extends BaseMapperX<OpsDeviceTrajectoryDO> {
/**
* 查询设备在某区域最近一条未关闭的轨迹记录
*/
default OpsDeviceTrajectoryDO selectOpenRecord(Long deviceId, Long areaId) {
return selectOne(new LambdaQueryWrapperX<OpsDeviceTrajectoryDO>()
.eq(OpsDeviceTrajectoryDO::getDeviceId, deviceId)
.eq(OpsDeviceTrajectoryDO::getAreaId, areaId)
.isNull(OpsDeviceTrajectoryDO::getLeaveTime)
.orderByDesc(OpsDeviceTrajectoryDO::getEnterTime)
.last("LIMIT 1"));
}
/**
* 查询设备在某区域未关闭的轨迹记录(加锁,防止并发竞态)
*/
default OpsDeviceTrajectoryDO selectOpenRecordForUpdate(Long deviceId, Long areaId) {
return selectOne(new LambdaQueryWrapperX<OpsDeviceTrajectoryDO>()
.eq(OpsDeviceTrajectoryDO::getDeviceId, deviceId)
.eq(OpsDeviceTrajectoryDO::getAreaId, areaId)
.isNull(OpsDeviceTrajectoryDO::getLeaveTime)
.orderByDesc(OpsDeviceTrajectoryDO::getEnterTime)
.last("LIMIT 1 FOR UPDATE"));
}
/**
* 查询设备在某区域、按进入时间精确匹配的未关闭轨迹记录(加锁)
*/
default OpsDeviceTrajectoryDO selectOpenRecordByEnterTimeForUpdate(Long deviceId, Long areaId,
LocalDateTime enterTime) {
return selectOne(new LambdaQueryWrapperX<OpsDeviceTrajectoryDO>()
.eq(OpsDeviceTrajectoryDO::getDeviceId, deviceId)
.eq(OpsDeviceTrajectoryDO::getAreaId, areaId)
.eq(OpsDeviceTrajectoryDO::getEnterTime, enterTime)
.isNull(OpsDeviceTrajectoryDO::getLeaveTime)
.last("LIMIT 1 FOR UPDATE"));
}
/**
* 分页查询轨迹记录
*/
default PageResult<OpsDeviceTrajectoryDO> selectPage(TrajectoryPageReqDTO req) {
return selectPage(req, new LambdaQueryWrapperX<OpsDeviceTrajectoryDO>()
.eqIfPresent(OpsDeviceTrajectoryDO::getDeviceId, req.getDeviceId())
.eqIfPresent(OpsDeviceTrajectoryDO::getAreaId, req.getAreaId())
.betweenIfPresent(OpsDeviceTrajectoryDO::getEnterTime, req.getEnterTime())
.orderByDesc(OpsDeviceTrajectoryDO::getEnterTime));
}
/**
* 查询某天的轨迹记录deviceId 可选,为 null 则查全部设备)
* <p>
* 安全上限 5000 条,防止全量加载过多数据到内存
*/
default List<OpsDeviceTrajectoryDO> selectByDateAndDevice(LocalDate date, Long deviceId) {
LocalDateTime start = date.atStartOfDay();
LocalDateTime end = date.plusDays(1).atStartOfDay();
return selectList(new LambdaQueryWrapperX<OpsDeviceTrajectoryDO>()
.eqIfPresent(OpsDeviceTrajectoryDO::getDeviceId, deviceId)
.ge(OpsDeviceTrajectoryDO::getEnterTime, start)
.lt(OpsDeviceTrajectoryDO::getEnterTime, end)
.orderByAsc(OpsDeviceTrajectoryDO::getEnterTime)
.last("LIMIT 5000"));
}
}

View File

@@ -7,10 +7,8 @@ import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.*;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
/**
@@ -25,6 +23,8 @@ import java.util.stream.Collectors;
@Component
public class AreaPathBuilder {
private static final Pattern DIGITS = Pattern.compile("\\d+");
@Resource
private OpsBusAreaMapper opsBusAreaMapper;
@@ -47,7 +47,7 @@ public class AreaPathBuilder {
// 解析父级ID列表
List<Long> parentIds = Arrays.stream(parentPath.split("/"))
.filter(StrUtil::isNotBlank)
.filter(pid -> pid.matches("\\d+"))
.filter(pid -> DIGITS.matcher(pid).matches())
.map(Long::parseLong)
.filter(pid -> !pid.equals(area.getId()))
.collect(Collectors.toList());
@@ -105,4 +105,87 @@ public class AreaPathBuilder {
return buildPath(area);
}
/**
* 批量构建区域路径(只执行一次父级查询)
*
* @param areas 区域对象集合
* @return areaId → 完整路径 的映射
*/
public Map<Long, String> buildPaths(Collection<OpsBusAreaDO> areas) {
if (areas == null || areas.isEmpty()) {
return Collections.emptyMap();
}
// 1. 收集所有父级 ID
Set<Long> allParentIds = new HashSet<>();
for (OpsBusAreaDO area : areas) {
if (StrUtil.isNotEmpty(area.getParentPath())) {
for (String idStr : area.getParentPath().split("/")) {
if (StrUtil.isNotBlank(idStr) && DIGITS.matcher(idStr).matches()) {
allParentIds.add(Long.parseLong(idStr));
}
}
}
}
// 排除自身已在集合中的
Set<Long> areaIds = areas.stream().map(OpsBusAreaDO::getId).collect(Collectors.toSet());
allParentIds.removeAll(areaIds);
// 2. 一次性查询所有父级
Map<Long, String> parentNameMap = new HashMap<>();
for (OpsBusAreaDO area : areas) {
parentNameMap.put(area.getId(), area.getAreaName());
}
if (!allParentIds.isEmpty()) {
List<OpsBusAreaDO> parents = opsBusAreaMapper.selectBatchIds(allParentIds);
if (parents != null) {
for (OpsBusAreaDO parent : parents) {
parentNameMap.put(parent.getId(), parent.getAreaName());
}
}
}
// 3. 纯内存拼路径
Map<Long, String> result = new HashMap<>(areas.size());
for (OpsBusAreaDO area : areas) {
result.put(area.getId(), buildPathFromCache(area, parentNameMap));
}
return result;
}
/**
* 使用预加载的名称缓存构建路径(纯内存,无 DB 查询)
*/
private String buildPathFromCache(OpsBusAreaDO area, Map<Long, String> nameMap) {
String parentPath = area.getParentPath();
if (StrUtil.isEmpty(parentPath)) {
return area.getAreaName();
}
List<Long> parentIds = Arrays.stream(parentPath.split("/"))
.filter(StrUtil::isNotBlank)
.filter(pid -> DIGITS.matcher(pid).matches())
.map(Long::parseLong)
.filter(pid -> !pid.equals(area.getId()))
.collect(Collectors.toList());
// 去重相邻重复
List<String> segments = new ArrayList<>();
Long lastId = null;
for (Long pid : parentIds) {
if (!pid.equals(lastId)) {
String name = nameMap.get(pid);
if (name != null) {
segments.add(name);
}
lastId = pid;
}
}
if (segments.isEmpty()) {
return area.getAreaName();
}
return String.join("/", segments) + "/" + area.getAreaName();
}
}

View File

@@ -82,6 +82,23 @@ public interface AreaDeviceService {
*/
List<Long> getDeviceIdsByAreaAndType(Long areaId, String relationType);
/**
* 查询所有启用的指定类型设备关联
*
* @param relationType 关联类型BADGE/BEACON/TRAFFIC_COUNTER
* @return 所有启用的指定类型关联关系
*/
List<OpsAreaDeviceRelationDO> listAllByType(String relationType);
/**
* 查询所有启用的 Beacon 设备关联
* <p>
* 用于轨迹检测功能,获取全量 Beacon 注册表
*
* @return 所有启用的 Beacon 类型关联关系
*/
List<OpsAreaDeviceRelationDO> listAllEnabledBeacons();
/**
* 初始化区域设备配置缓存
* <p>

View File

@@ -1,6 +1,7 @@
package com.viewsh.module.ops.service.area;
import com.viewsh.framework.common.util.json.JsonUtils;
import com.viewsh.framework.tenant.core.aop.TenantIgnore;
import com.viewsh.module.ops.api.area.AreaDeviceDTO;
import com.viewsh.module.ops.dal.dataobject.area.OpsAreaDeviceRelationDO;
import com.viewsh.module.ops.dal.mysql.area.OpsAreaDeviceRelationMapper;
@@ -163,6 +164,19 @@ public class AreaDeviceServiceImpl implements AreaDeviceService, InitializingBea
}
@Override
@TenantIgnore // RPC 接口,跨租户查询全量设备
public List<OpsAreaDeviceRelationDO> listAllByType(String relationType) {
return relationMapper.selectListByAreaIdAndRelationType(null, relationType);
}
@Override
@TenantIgnore // RPC 接口,跨租户查询全量 Beacon
public List<OpsAreaDeviceRelationDO> listAllEnabledBeacons() {
return listAllByType("BEACON");
}
@Override
@TenantIgnore // 启动预热,跨租户加载全量配置
public void initConfigCache() {
log.info("[AreaDevice] 开始初始化区域设备配置缓存...");

View File

@@ -151,4 +151,14 @@ public interface UserDispatchStatusService {
* @return 活跃工单数,无记录返回 0
*/
int getActiveOrderCount(Long userId);
/**
* 启动校准:从 DB 重建人员调度状态
* <p>
* 扫描 Redis 中所有 ops:user:dispatch:* key校验 currentOrderId 对应的工单是否仍在进行中,
* 并根据 DB 中实际活跃工单数修正 activeOrderCount / waitingTaskCount / status。
*
* @return 校准的用户数
*/
int calibrateFromDb();
}

View File

@@ -1,6 +1,9 @@
package com.viewsh.module.ops.service.dispatch;
import com.viewsh.module.ops.api.dispatch.UserDispatchStatusDTO;
import com.viewsh.module.ops.dal.dataobject.workorder.OpsOrderDO;
import com.viewsh.module.ops.dal.mysql.workorder.OpsOrderMapper;
import com.viewsh.module.ops.enums.WorkOrderStatusEnum;
import com.viewsh.module.ops.infrastructure.redis.OpsRedisKeyBuilder;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
@@ -10,6 +13,7 @@ import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Service;
import java.util.*;
import java.util.stream.Collectors;
/**
* 通用人员调度状态服务 - Redis 实现
@@ -31,6 +35,9 @@ public class UserDispatchStatusServiceImpl implements UserDispatchStatusService
@Resource
private StringRedisTemplate stringRedisTemplate;
@Resource
private OpsOrderMapper opsOrderMapper;
// ==================== Lua 脚本 ====================
private DefaultRedisScript<Long> dispatchScript;
@@ -297,6 +304,107 @@ public class UserDispatchStatusServiceImpl implements UserDispatchStatusService
}
}
// ==================== 启动校准 ====================
@Override
public int calibrateFromDb() {
// 1. SCAN 所有 user dispatch key
Set<String> keys = stringRedisTemplate.keys(KEY_PREFIX + "*");
if (keys == null || keys.isEmpty()) {
return 0;
}
int calibrated = 0;
for (String key : keys) {
try {
String userIdStr = key.substring(KEY_PREFIX.length());
Long userId = Long.parseLong(userIdStr);
// 2. 查 DB该用户所有非终态、非保洁的活跃工单
List<OpsOrderDO> activeOrders = opsOrderMapper.selectList(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<OpsOrderDO>()
.eq(OpsOrderDO::getAssigneeId, userId)
.ne(OpsOrderDO::getOrderType, "CLEAN")
.notIn(OpsOrderDO::getStatus,
WorkOrderStatusEnum.COMPLETED.getStatus(),
WorkOrderStatusEnum.CANCELLED.getStatus())
);
if (activeOrders.isEmpty()) {
// 无活跃工单,但 Redis 中有记录 → 应该是 IDLE
Map<Object, Object> current = stringRedisTemplate.opsForHash().entries(key);
String currentStatus = current.get("status") != null ? current.get("status").toString() : null;
if (!"IDLE".equals(currentStatus) || !"0".equals(Objects.toString(current.get("activeOrderCount"), "0"))) {
// 重置为 IDLE
stringRedisTemplate.opsForHash().putAll(key, Map.of(
"status", "IDLE",
"activeOrderCount", "0",
"waitingTaskCount", "0",
"lastUpdateTime", String.valueOf(System.currentTimeMillis())
));
stringRedisTemplate.opsForHash().delete(key, "currentOrderId", "currentOrderType", "currentOrderStatus");
log.info("[校准] 人员状态重置为IDLE: userId={}, 原状态={}", userId, currentStatus);
calibrated++;
}
} else {
// 有活跃工单 → 重建计数
int activeCount = activeOrders.size();
int waitingCount = (int) activeOrders.stream()
.filter(o -> WorkOrderStatusEnum.QUEUED.getStatus().equals(o.getStatus()))
.count();
// 找当前正在执行的工单DISPATCHED/CONFIRMED/ARRIVED 中最新的)
OpsOrderDO currentOrder = activeOrders.stream()
.filter(o -> {
String s = o.getStatus();
return WorkOrderStatusEnum.DISPATCHED.getStatus().equals(s)
|| WorkOrderStatusEnum.CONFIRMED.getStatus().equals(s)
|| WorkOrderStatusEnum.ARRIVED.getStatus().equals(s);
})
.max(Comparator.comparing(OpsOrderDO::getUpdateTime))
.orElse(null);
// 判断是否有暂停的工单
boolean hasPaused = activeOrders.stream()
.anyMatch(o -> WorkOrderStatusEnum.PAUSED.getStatus().equals(o.getStatus()));
String status = hasPaused ? "PAUSED" : "BUSY";
Map<String, String> newHash = new HashMap<>();
newHash.put("status", status);
newHash.put("activeOrderCount", String.valueOf(activeCount));
newHash.put("waitingTaskCount", String.valueOf(waitingCount));
newHash.put("lastUpdateTime", String.valueOf(System.currentTimeMillis()));
if (currentOrder != null) {
newHash.put("currentOrderId", String.valueOf(currentOrder.getId()));
newHash.put("currentOrderType", currentOrder.getOrderType());
newHash.put("currentOrderStatus", currentOrder.getStatus());
}
// 比对 Redis 当前值,有差异则修正
Map<Object, Object> current = stringRedisTemplate.opsForHash().entries(key);
boolean needUpdate = !String.valueOf(activeCount).equals(Objects.toString(current.get("activeOrderCount"), ""))
|| !String.valueOf(waitingCount).equals(Objects.toString(current.get("waitingTaskCount"), ""))
|| !status.equals(Objects.toString(current.get("status"), ""));
if (needUpdate) {
stringRedisTemplate.delete(key);
stringRedisTemplate.opsForHash().putAll(key, newHash);
stringRedisTemplate.expire(key, java.time.Duration.ofSeconds(TTL_SECONDS));
log.info("[校准] 人员状态已修正: userId={}, status={}, active={}, waiting={}",
userId, status, activeCount, waitingCount);
calibrated++;
}
}
} catch (Exception e) {
log.warn("[校准] 人员状态校准失败: key={}", key, e);
}
}
return calibrated;
}
// ==================== Lua 脚本定义 ====================
/**

View File

@@ -0,0 +1,33 @@
package com.viewsh.module.ops.service.trajectory.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 区域停留分布 Response DTO
*
* @author lzh
*/
@Schema(description = "管理后台 - 区域停留分布 Response DTO")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AreaStayStatsDTO {
@Schema(description = "区域名称", example = "男卫")
private String areaName;
@Schema(description = "完整区域路径", example = "A园区/A栋/3层/男卫")
private String fullAreaName;
@Schema(description = "总停留时长(秒)", example = "3600")
private Long totalStaySeconds;
@Schema(description = "访问次数", example = "12")
private Long visitCount;
}

View File

@@ -0,0 +1,30 @@
package com.viewsh.module.ops.service.trajectory.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 工牌设备精简 Response DTO下拉列表用
*
* @author lzh
*/
@Schema(description = "管理后台 - 工牌设备精简信息(下拉列表用)")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class BadgeSimpleRespDTO {
@Schema(description = "设备ID", example = "31")
private Long deviceId;
@Schema(description = "设备Key", example = "09207455611")
private String deviceKey;
@Schema(description = "设备备注名称", example = "1号工牌")
private String nickname;
}

View File

@@ -0,0 +1,39 @@
package com.viewsh.module.ops.service.trajectory.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 设备实时位置 Response DTO
*
* @author lzh
*/
@Schema(description = "管理后台 - 设备实时位置 Response DTO")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class DeviceCurrentLocationDTO {
@Schema(description = "设备ID", example = "31")
private Long deviceId;
@Schema(description = "当前所在区域ID", example = "1301")
private Long areaId;
@Schema(description = "区域名称", example = "A座2楼男卫")
private String areaName;
@Schema(description = "进入时间(毫秒时间戳)", example = "1711872600000")
private Long enterTime;
@Schema(description = "匹配的Beacon MAC", example = "F0:C8:60:1D:10:BB")
private String beaconMac;
@Schema(description = "是否在某区域内")
private Boolean inArea;
}

View File

@@ -0,0 +1,30 @@
package com.viewsh.module.ops.service.trajectory.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 时段出入趋势 Response DTO
*
* @author lzh
*/
@Schema(description = "管理后台 - 时段出入趋势 Response DTO")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class HourlyTrendDTO {
@Schema(description = "小时0-23", example = "8")
private Integer hour;
@Schema(description = "进入次数", example = "8")
private Long enterCount;
@Schema(description = "离开次数", example = "6")
private Long leaveCount;
}

View File

@@ -0,0 +1,35 @@
package com.viewsh.module.ops.service.trajectory.dto;
import com.viewsh.framework.common.pojo.PageParam;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Size;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.springframework.format.annotation.DateTimeFormat;
import java.time.LocalDateTime;
import static com.viewsh.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND;
/**
* 轨迹分页查询 Request DTO
*
* @author lzh
*/
@Schema(description = "管理后台 - 设备轨迹分页查询 Request DTO")
@Data
@EqualsAndHashCode(callSuper = true)
public class TrajectoryPageReqDTO extends PageParam {
@Schema(description = "设备ID", example = "31")
private Long deviceId;
@Schema(description = "区域ID", example = "1301")
private Long areaId;
@Schema(description = "进入时间范围(开始时间, 结束时间)")
@DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
@Size(min = 2, max = 2, message = "进入时间范围必须包含开始时间和结束时间")
private LocalDateTime[] enterTime;
}

View File

@@ -0,0 +1,56 @@
package com.viewsh.module.ops.service.trajectory.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 轨迹记录 Response DTO
*
* @author lzh
*/
@Schema(description = "管理后台 - 设备轨迹记录 Response DTO")
@Data
public class TrajectoryRespDTO {
@Schema(description = "记录ID", example = "1")
private Long id;
@Schema(description = "设备ID", example = "31")
private Long deviceId;
@Schema(description = "设备名称", example = "badge_001")
private String deviceName;
@Schema(description = "设备备注名称", example = "1号工牌")
private String nickname;
@Schema(description = "区域ID", example = "1301")
private Long areaId;
@Schema(description = "区域名称", example = "A座2楼男卫")
private String areaName;
@Schema(description = "完整区域路径", example = "A园区/A栋/3层/男卫")
private String fullAreaName;
@Schema(description = "Beacon MAC", example = "F0:C8:60:1D:10:BB")
private String beaconMac;
@Schema(description = "进入时间")
private LocalDateTime enterTime;
@Schema(description = "离开时间")
private LocalDateTime leaveTime;
@Schema(description = "停留时长(秒)", example = "300")
private Integer durationSeconds;
@Schema(description = "离开原因", example = "SIGNAL_LOSS")
private String leaveReason;
@Schema(description = "进入时RSSI", example = "-65")
private Integer enterRssi;
}

View File

@@ -0,0 +1,29 @@
package com.viewsh.module.ops.service.trajectory.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import org.springframework.format.annotation.DateTimeFormat;
import java.time.LocalDate;
/**
* 轨迹统计查询 Request DTO
* <p>
* 用于 summary / hourly-trend / area-stay-stats 三个统计接口
*
* @author lzh
*/
@Schema(description = "管理后台 - 轨迹统计查询 Request DTO")
@Data
public class TrajectoryStatsReqDTO {
@Schema(description = "查询日期", requiredMode = Schema.RequiredMode.REQUIRED, example = "2026-04-05")
@NotNull(message = "查询日期不能为空")
@DateTimeFormat(pattern = "yyyy-MM-dd")
private LocalDate date;
@Schema(description = "设备ID不传=全部设备汇总)", example = "31")
private Long deviceId;
}

View File

@@ -0,0 +1,35 @@
package com.viewsh.module.ops.service.trajectory.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 轨迹统计摘要 Response DTO
* <p>
* 用于 KPI 卡片展示
*
* @author lzh
*/
@Schema(description = "管理后台 - 轨迹统计摘要 Response DTO")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class TrajectorySummaryDTO {
@Schema(description = "总作业时长(秒)", example = "7200")
private Long workDurationSeconds;
@Schema(description = "覆盖区域数", example = "5")
private Long coveredAreaCount;
@Schema(description = "总出入事件数", example = "42")
private Long totalEvents;
@Schema(description = "平均停留时长(秒)", example = "171")
private Long avgStaySeconds;
}

View File

@@ -0,0 +1,27 @@
package com.viewsh.module.ops.service.trajectory.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import org.springframework.format.annotation.DateTimeFormat;
import java.time.LocalDate;
/**
* 轨迹时间线查询 Request DTO
*
* @author lzh
*/
@Schema(description = "管理后台 - 设备轨迹时间线查询 Request DTO")
@Data
public class TrajectoryTimelineReqDTO {
@Schema(description = "设备ID不传=全部设备)", example = "31")
private Long deviceId;
@Schema(description = "查询日期", requiredMode = Schema.RequiredMode.REQUIRED, example = "2026-03-30")
@NotNull(message = "查询日期不能为空")
@DateTimeFormat(pattern = "yyyy-MM-dd")
private LocalDate date;
}