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:
@@ -6,6 +6,7 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
@@ -23,6 +24,8 @@ import java.util.Map;
|
||||
@Repository
|
||||
public class TrafficActiveOrderRedisDAO {
|
||||
|
||||
private static final Duration ACTIVE_ORDER_TTL = Duration.ofHours(24);
|
||||
|
||||
private static final String FIELD_ORDER_ID = "orderId";
|
||||
private static final String FIELD_STATUS = "status";
|
||||
private static final String FIELD_PRIORITY = "priority";
|
||||
@@ -40,6 +43,7 @@ public class TrafficActiveOrderRedisDAO {
|
||||
FIELD_STATUS, status,
|
||||
FIELD_PRIORITY, String.valueOf(priority)
|
||||
));
|
||||
stringRedisTemplate.expire(key, ACTIVE_ORDER_TTL);
|
||||
log.debug("[TrafficActiveOrderRedisDAO] 标记活跃工单: areaId={}, orderId={}, status={}, priority={}",
|
||||
areaId, orderId, status, priority);
|
||||
}
|
||||
@@ -75,6 +79,7 @@ public class TrafficActiveOrderRedisDAO {
|
||||
String key = buildKey(areaId);
|
||||
if (Boolean.TRUE.equals(stringRedisTemplate.hasKey(key))) {
|
||||
stringRedisTemplate.opsForHash().put(key, FIELD_STATUS, newStatus);
|
||||
stringRedisTemplate.expire(key, ACTIVE_ORDER_TTL);
|
||||
log.debug("[TrafficActiveOrderRedisDAO] 更新状态: areaId={}, newStatus={}", areaId, newStatus);
|
||||
}
|
||||
}
|
||||
@@ -86,6 +91,7 @@ public class TrafficActiveOrderRedisDAO {
|
||||
String key = buildKey(areaId);
|
||||
if (Boolean.TRUE.equals(stringRedisTemplate.hasKey(key))) {
|
||||
stringRedisTemplate.opsForHash().put(key, FIELD_PRIORITY, String.valueOf(newPriority));
|
||||
stringRedisTemplate.expire(key, ACTIVE_ORDER_TTL);
|
||||
log.debug("[TrafficActiveOrderRedisDAO] 更新优先级: areaId={}, newPriority={}", areaId, newPriority);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,6 +164,34 @@ public class CleanOrderCreateEventHandler implements RocketMQListener<String> {
|
||||
}
|
||||
}
|
||||
|
||||
// 1.5 Redis 命中时,回查 DB 校验真实状态(防止缓存与 DB 不一致)
|
||||
if (activeOrder != null) {
|
||||
OpsOrderDO dbOrder = opsOrderMapper.selectById(activeOrder.getOrderId());
|
||||
if (dbOrder == null
|
||||
|| WorkOrderStatusEnum.COMPLETED.getStatus().equals(dbOrder.getStatus())
|
||||
|| WorkOrderStatusEnum.CANCELLED.getStatus().equals(dbOrder.getStatus())) {
|
||||
// 工单已终态或已删除,Redis 标记过期,清除
|
||||
log.warn("[CleanOrderCreateEventHandler] Redis活跃标记已过期(DB状态:{}),清除: areaId={}, orderId={}",
|
||||
dbOrder != null ? dbOrder.getStatus() : "NOT_FOUND", areaId, activeOrder.getOrderId());
|
||||
trafficActiveOrderRedisDAO.removeActive(areaId);
|
||||
activeOrder = null;
|
||||
} else if (!activeOrder.getStatus().equals(dbOrder.getStatus())
|
||||
|| !activeOrder.getPriority().equals(dbOrder.getPriority())) {
|
||||
// Redis 状态/优先级过期,用 DB 真实值刷新
|
||||
log.info("[CleanOrderCreateEventHandler] Redis状态过期已刷新: areaId={}, orderId={}, redis={}/{}, db={}/{}",
|
||||
areaId, activeOrder.getOrderId(),
|
||||
activeOrder.getStatus(), activeOrder.getPriority(),
|
||||
dbOrder.getStatus(), dbOrder.getPriority());
|
||||
activeOrder = ActiveOrderInfo.builder()
|
||||
.orderId(dbOrder.getId())
|
||||
.status(dbOrder.getStatus())
|
||||
.priority(dbOrder.getPriority())
|
||||
.build();
|
||||
trafficActiveOrderRedisDAO.markActive(areaId, dbOrder.getId(),
|
||||
dbOrder.getStatus(), dbOrder.getPriority());
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 有活跃工单 → 升级或忽略
|
||||
if (activeOrder != null) {
|
||||
String status = activeOrder.getStatus();
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.viewsh.module.ops.environment.integration.consumer;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.viewsh.module.ops.environment.integration.dto.TrajectoryEnterEventDTO;
|
||||
import com.viewsh.module.ops.environment.service.trajectory.DeviceTrajectoryService;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.rocketmq.spring.annotation.ConsumeMode;
|
||||
import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
|
||||
import org.apache.rocketmq.spring.core.RocketMQListener;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.format.DateTimeParseException;
|
||||
|
||||
/**
|
||||
* 轨迹进入区域事件消费者
|
||||
* <p>
|
||||
* 订阅 IoT 模块发布的轨迹进入事件,创建轨迹记录
|
||||
*
|
||||
* @author lzh
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RocketMQMessageListener(
|
||||
topic = "trajectory-enter",
|
||||
consumerGroup = "ops-trajectory-enter-group",
|
||||
consumeMode = ConsumeMode.CONCURRENTLY,
|
||||
selectorExpression = "*",
|
||||
accessKey = "${rocketmq.consumer.access-key:}",
|
||||
secretKey = "${rocketmq.consumer.secret-key:}"
|
||||
)
|
||||
public class TrajectoryEnterEventHandler implements RocketMQListener<String> {
|
||||
|
||||
@Resource
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
@Resource
|
||||
private IntegrationEventDeduplicationService deduplicationService;
|
||||
|
||||
@Resource
|
||||
private DeviceTrajectoryService trajectoryService;
|
||||
|
||||
@Override
|
||||
public void onMessage(String message) {
|
||||
try {
|
||||
TrajectoryEnterEventDTO event = objectMapper.readValue(message, TrajectoryEnterEventDTO.class);
|
||||
|
||||
// 幂等性检查
|
||||
if (!deduplicationService.tryConsume(event.getEventId())) {
|
||||
log.debug("[TrajectoryEnterHandler] 重复消息,跳过:eventId={}", event.getEventId());
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("[TrajectoryEnterHandler] 收到进入事件:eventId={}, deviceId={}, areaId={}",
|
||||
event.getEventId(), event.getDeviceId(), event.getAreaId());
|
||||
|
||||
// 解析事件时间
|
||||
LocalDateTime enterTime = parseEventTime(event.getEventTime());
|
||||
|
||||
// 创建轨迹记录
|
||||
trajectoryService.recordEnter(
|
||||
event.getDeviceId(),
|
||||
event.getDeviceName(),
|
||||
event.getNickname(),
|
||||
event.getAreaId(),
|
||||
event.getBeaconMac(),
|
||||
event.getEnterRssi(),
|
||||
enterTime);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("[TrajectoryEnterHandler] 消息处理失败:message={}", message, e);
|
||||
throw new RuntimeException("轨迹进入事件处理失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
private LocalDateTime parseEventTime(String eventTime) {
|
||||
if (eventTime == null || eventTime.isEmpty()) {
|
||||
return LocalDateTime.now();
|
||||
}
|
||||
try {
|
||||
return LocalDateTime.parse(eventTime, DateTimeFormatter.ISO_LOCAL_DATE_TIME);
|
||||
} catch (DateTimeParseException e) {
|
||||
log.warn("[TrajectoryEnterHandler] 事件时间解析失败,使用当前时间:eventTime={}", eventTime, e);
|
||||
return LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package com.viewsh.module.ops.environment.integration.consumer;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.viewsh.module.ops.environment.integration.dto.TrajectoryLeaveEventDTO;
|
||||
import com.viewsh.module.ops.environment.service.trajectory.DeviceTrajectoryService;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.rocketmq.spring.annotation.ConsumeMode;
|
||||
import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
|
||||
import org.apache.rocketmq.spring.core.RocketMQListener;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.format.DateTimeParseException;
|
||||
|
||||
/**
|
||||
* 轨迹离开区域事件消费者
|
||||
* <p>
|
||||
* 订阅 IoT 模块发布的轨迹离开事件,更新轨迹记录的离开信息
|
||||
*
|
||||
* @author lzh
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RocketMQMessageListener(
|
||||
topic = "trajectory-leave",
|
||||
consumerGroup = "ops-trajectory-leave-group",
|
||||
consumeMode = ConsumeMode.CONCURRENTLY,
|
||||
selectorExpression = "*",
|
||||
accessKey = "${rocketmq.consumer.access-key:}",
|
||||
secretKey = "${rocketmq.consumer.secret-key:}"
|
||||
)
|
||||
public class TrajectoryLeaveEventHandler implements RocketMQListener<String> {
|
||||
|
||||
@Resource
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
@Resource
|
||||
private IntegrationEventDeduplicationService deduplicationService;
|
||||
|
||||
@Resource
|
||||
private DeviceTrajectoryService trajectoryService;
|
||||
|
||||
@Override
|
||||
public void onMessage(String message) {
|
||||
try {
|
||||
TrajectoryLeaveEventDTO event = objectMapper.readValue(message, TrajectoryLeaveEventDTO.class);
|
||||
|
||||
// 幂等性检查
|
||||
if (!deduplicationService.tryConsume(event.getEventId())) {
|
||||
log.debug("[TrajectoryLeaveHandler] 重复消息,跳过:eventId={}", event.getEventId());
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("[TrajectoryLeaveHandler] 收到离开事件:eventId={}, deviceId={}, areaId={}, reason={}",
|
||||
event.getEventId(), event.getDeviceId(), event.getAreaId(), event.getLeaveReason());
|
||||
|
||||
// 解析事件时间
|
||||
LocalDateTime leaveTime = parseEventTime(event.getEventTime());
|
||||
|
||||
// 更新轨迹记录
|
||||
trajectoryService.recordLeave(
|
||||
event.getDeviceId(),
|
||||
event.getAreaId(),
|
||||
event.getLeaveReason(),
|
||||
event.getEnterTimestamp(),
|
||||
leaveTime);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("[TrajectoryLeaveHandler] 消息处理失败:message={}", message, e);
|
||||
throw new RuntimeException("轨迹离开事件处理失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
private LocalDateTime parseEventTime(String eventTime) {
|
||||
if (eventTime == null || eventTime.isEmpty()) {
|
||||
return LocalDateTime.now();
|
||||
}
|
||||
try {
|
||||
return LocalDateTime.parse(eventTime, DateTimeFormatter.ISO_LOCAL_DATE_TIME);
|
||||
} catch (DateTimeParseException e) {
|
||||
log.warn("[TrajectoryLeaveHandler] 事件时间解析失败,使用当前时间:eventTime={}", eventTime, e);
|
||||
return LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.viewsh.module.ops.environment.integration.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 轨迹进入区域事件 DTO
|
||||
* <p>
|
||||
* 由 IoT 模块发布,Ops 模块消费
|
||||
*
|
||||
* @author lzh
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class TrajectoryEnterEventDTO {
|
||||
|
||||
/**
|
||||
* 事件ID(UUID,用于幂等性控制)
|
||||
*/
|
||||
private String eventId;
|
||||
|
||||
/**
|
||||
* 设备ID(工牌)
|
||||
*/
|
||||
private Long deviceId;
|
||||
|
||||
/**
|
||||
* 设备名称
|
||||
*/
|
||||
private String deviceName;
|
||||
|
||||
/**
|
||||
* 设备备注名称
|
||||
*/
|
||||
private String nickname;
|
||||
|
||||
/**
|
||||
* 区域ID
|
||||
*/
|
||||
private Long areaId;
|
||||
|
||||
/**
|
||||
* 匹配的 Beacon MAC 地址
|
||||
*/
|
||||
private String beaconMac;
|
||||
|
||||
/**
|
||||
* 进入时的 RSSI 值
|
||||
*/
|
||||
private Integer enterRssi;
|
||||
|
||||
/**
|
||||
* 事件时间(ISO 格式)
|
||||
*/
|
||||
private String eventTime;
|
||||
|
||||
/**
|
||||
* 租户ID
|
||||
*/
|
||||
private Long tenantId;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.viewsh.module.ops.environment.integration.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 轨迹离开区域事件 DTO
|
||||
* <p>
|
||||
* 由 IoT 模块发布,Ops 模块消费
|
||||
*
|
||||
* @author lzh
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class TrajectoryLeaveEventDTO {
|
||||
|
||||
/**
|
||||
* 事件ID(UUID,用于幂等性控制)
|
||||
*/
|
||||
private String eventId;
|
||||
|
||||
/**
|
||||
* 设备ID(工牌)
|
||||
*/
|
||||
private Long deviceId;
|
||||
|
||||
/**
|
||||
* 设备名称
|
||||
*/
|
||||
private String deviceName;
|
||||
|
||||
/**
|
||||
* 设备备注名称
|
||||
*/
|
||||
private String nickname;
|
||||
|
||||
/**
|
||||
* 区域ID
|
||||
*/
|
||||
private Long areaId;
|
||||
|
||||
/**
|
||||
* 匹配的 Beacon MAC 地址
|
||||
*/
|
||||
private String beaconMac;
|
||||
|
||||
/**
|
||||
* 离开原因
|
||||
* <p>
|
||||
* SIGNAL_LOSS / AREA_SWITCH / DEVICE_OFFLINE
|
||||
*/
|
||||
private String leaveReason;
|
||||
|
||||
/**
|
||||
* 进入时间戳(毫秒),用于匹配轨迹记录
|
||||
*/
|
||||
private Long enterTimestamp;
|
||||
|
||||
/**
|
||||
* 事件时间(ISO 格式)
|
||||
*/
|
||||
private String eventTime;
|
||||
|
||||
/**
|
||||
* 租户ID
|
||||
*/
|
||||
private Long tenantId;
|
||||
}
|
||||
@@ -194,9 +194,10 @@ public class CleanOrderEventListener {
|
||||
clearTrafficActiveOrderOnComplete(event);
|
||||
break;
|
||||
case CANCELLED:
|
||||
|
||||
handleCancelled(event);
|
||||
// ★ 先清 Redis 活跃标记,再处理取消逻辑
|
||||
// 确保即使 handleCancelled 异常,Redis 标记也能被清除
|
||||
clearTrafficActiveOrder(event);
|
||||
handleCancelled(event);
|
||||
break;
|
||||
case QUEUED:
|
||||
handleQueued(event);
|
||||
|
||||
@@ -12,6 +12,7 @@ import com.viewsh.module.ops.dal.mysql.area.OpsAreaDeviceRelationMapper;
|
||||
import com.viewsh.module.ops.enums.BadgeDeviceStatusEnum;
|
||||
import com.viewsh.module.ops.environment.integration.dto.IotDeviceStatusChangedEventDTO;
|
||||
import com.viewsh.module.ops.environment.service.badge.BadgeDeviceStatusService;
|
||||
import com.viewsh.module.ops.environment.service.voice.TtsQueueConsumer;
|
||||
import com.xxl.job.core.handler.annotation.XxlJob;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -52,6 +53,9 @@ public class BadgeDeviceStatusSyncJob {
|
||||
@Resource
|
||||
private BadgeDeviceStatusService badgeDeviceStatusService;
|
||||
|
||||
@Resource
|
||||
private TtsQueueConsumer ttsQueueConsumer;
|
||||
|
||||
/**
|
||||
* 执行全量对账
|
||||
* <p>
|
||||
@@ -122,6 +126,12 @@ public class BadgeDeviceStatusSyncJob {
|
||||
boolean orderRepaired = badgeDeviceStatusService.repairDeviceOrderConsistency(iotStatus.getDeviceId());
|
||||
if (orderRepaired) {
|
||||
orderRepairedCount++;
|
||||
// 工单已清除,同时清理可能残留的 TTS 循环播报标记
|
||||
try {
|
||||
ttsQueueConsumer.stopLoop(iotStatus.getDeviceId());
|
||||
} catch (Exception e) {
|
||||
log.warn("[SyncJob] 清理TTS循环播报失败: deviceId={}", iotStatus.getDeviceId(), e);
|
||||
}
|
||||
}
|
||||
|
||||
// 4b. IoT 在线/离线状态对账
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
package com.viewsh.module.ops.environment.job;
|
||||
|
||||
import com.viewsh.framework.tenant.core.util.TenantUtils;
|
||||
import com.viewsh.module.ops.dal.dataobject.workorder.OpsOrderDO;
|
||||
import com.viewsh.module.ops.dal.mysql.workorder.OpsOrderMapper;
|
||||
import com.viewsh.module.ops.environment.dal.redis.TrafficActiveOrderRedisDAO;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.ApplicationArguments;
|
||||
import org.springframework.boot.ApplicationRunner;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 客流活跃工单 Redis 启动校准器
|
||||
* <p>
|
||||
* 职责:服务启动时,扫描所有 ops:clean:traffic:active-order:* key,
|
||||
* 逐个与 DB 比对,清理已终态(COMPLETED/CANCELLED)或已删除的残留标记。
|
||||
* <p>
|
||||
* 解决场景:
|
||||
* - 服务重启期间 Spring Event 丢失,导致 removeActive 未执行
|
||||
* - 异常导致 clearTrafficActiveOrder 被跳过
|
||||
*
|
||||
* @author AI
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class TrafficActiveOrderInitializer implements ApplicationRunner {
|
||||
|
||||
private static final String KEY_PREFIX = "ops:clean:traffic:active-order:";
|
||||
|
||||
@Resource
|
||||
private StringRedisTemplate stringRedisTemplate;
|
||||
|
||||
@Resource
|
||||
private OpsOrderMapper opsOrderMapper;
|
||||
|
||||
@Resource
|
||||
private TrafficActiveOrderRedisDAO trafficActiveOrderRedisDAO;
|
||||
|
||||
@Override
|
||||
public void run(ApplicationArguments args) {
|
||||
log.info("[初始化] 开始校准客流活跃工单 Redis 标记...");
|
||||
|
||||
try {
|
||||
TenantUtils.executeIgnore(this::calibrate);
|
||||
} catch (Exception e) {
|
||||
log.error("[初始化] 客流活跃工单 Redis 校准失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void calibrate() {
|
||||
// SCAN 匹配所有活跃工单 key
|
||||
Set<String> keys = stringRedisTemplate.keys(KEY_PREFIX + "*");
|
||||
if (keys == null || keys.isEmpty()) {
|
||||
log.info("[初始化] 无客流活跃工单 Redis 标记,跳过校准");
|
||||
return;
|
||||
}
|
||||
|
||||
int total = keys.size();
|
||||
int cleaned = 0;
|
||||
int refreshed = 0;
|
||||
|
||||
for (String key : keys) {
|
||||
try {
|
||||
Map<Object, Object> entries = stringRedisTemplate.opsForHash().entries(key);
|
||||
String orderIdStr = (String) entries.get("orderId");
|
||||
if (orderIdStr == null) {
|
||||
stringRedisTemplate.delete(key);
|
||||
cleaned++;
|
||||
continue;
|
||||
}
|
||||
|
||||
Long orderId = Long.parseLong(orderIdStr);
|
||||
OpsOrderDO dbOrder = opsOrderMapper.selectById(orderId);
|
||||
|
||||
// 提取 areaId from key: ops:clean:traffic:active-order:{areaId}
|
||||
String areaIdStr = key.substring(KEY_PREFIX.length());
|
||||
Long areaId = Long.parseLong(areaIdStr);
|
||||
|
||||
if (dbOrder == null
|
||||
|| "COMPLETED".equals(dbOrder.getStatus())
|
||||
|| "CANCELLED".equals(dbOrder.getStatus())) {
|
||||
// 工单已终态或不存在,清除残留标记
|
||||
trafficActiveOrderRedisDAO.removeActive(areaId);
|
||||
log.info("[初始化] 清除过期活跃标记: areaId={}, orderId={}, dbStatus={}",
|
||||
areaId, orderId, dbOrder != null ? dbOrder.getStatus() : "NOT_FOUND");
|
||||
cleaned++;
|
||||
} else {
|
||||
// 工单仍活跃,用 DB 真实状态刷新 Redis
|
||||
String cachedStatus = (String) entries.get("status");
|
||||
String cachedPriority = (String) entries.get("priority");
|
||||
if (!dbOrder.getStatus().equals(cachedStatus)
|
||||
|| !String.valueOf(dbOrder.getPriority()).equals(cachedPriority)) {
|
||||
trafficActiveOrderRedisDAO.markActive(areaId, dbOrder.getId(),
|
||||
dbOrder.getStatus(), dbOrder.getPriority());
|
||||
log.info("[初始化] 刷新活跃标记状态: areaId={}, orderId={}, {}→{}",
|
||||
areaId, orderId, cachedStatus, dbOrder.getStatus());
|
||||
refreshed++;
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("[初始化] 校准单个 key 失败: key={}", key, e);
|
||||
}
|
||||
}
|
||||
|
||||
log.info("[初始化] 客流活跃工单 Redis 校准完成:总计 {} 个,清除 {} 个,刷新 {} 个",
|
||||
total, cleaned, refreshed);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
package com.viewsh.module.ops.environment.service.badge;
|
||||
|
||||
import com.viewsh.module.ops.api.badge.BadgeDeviceStatusDTO;
|
||||
import com.viewsh.module.ops.dal.dataobject.workorder.OpsOrderDO;
|
||||
import com.viewsh.module.ops.dal.mysql.workorder.OpsOrderMapper;
|
||||
import com.viewsh.module.ops.enums.BadgeDeviceStatusEnum;
|
||||
import com.viewsh.module.ops.enums.WorkOrderStatusEnum;
|
||||
import com.viewsh.module.ops.infrastructure.redis.OpsRedisKeyBuilder;
|
||||
import com.viewsh.module.ops.service.area.AreaDeviceService;
|
||||
import com.viewsh.module.ops.dal.dataobject.area.OpsBusAreaDO;
|
||||
import com.viewsh.module.ops.dal.dataobject.workorder.OpsOrderDO;
|
||||
import com.viewsh.module.ops.dal.mysql.area.OpsBusAreaMapper;
|
||||
import com.viewsh.module.ops.dal.mysql.workorder.OpsOrderMapper;
|
||||
import com.viewsh.module.ops.enums.BadgeDeviceStatusEnum;
|
||||
import com.viewsh.module.ops.enums.WorkOrderStatusEnum;
|
||||
import com.viewsh.module.ops.infrastructure.redis.OpsRedisKeyBuilder;
|
||||
import com.viewsh.module.ops.service.area.AreaDeviceService;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.InitializingBean;
|
||||
@@ -49,6 +51,9 @@ public class BadgeDeviceStatusServiceImpl implements BadgeDeviceStatusService, I
|
||||
@Resource
|
||||
private OpsOrderMapper opsOrderMapper;
|
||||
|
||||
@Resource
|
||||
private OpsBusAreaMapper opsBusAreaMapper;
|
||||
|
||||
@Resource
|
||||
private ApplicationEventPublisher eventPublisher;
|
||||
|
||||
@@ -75,7 +80,7 @@ public class BadgeDeviceStatusServiceImpl implements BadgeDeviceStatusService, I
|
||||
}
|
||||
|
||||
try {
|
||||
String key = OpsRedisKeyBuilder.badgeStatus(deviceId);
|
||||
String key = OpsRedisKeyBuilder.badgeStatus(deviceId);
|
||||
|
||||
// 获取当前状态
|
||||
BadgeDeviceStatusDTO currentStatus = getBadgeStatus(deviceId);
|
||||
@@ -150,7 +155,7 @@ public class BadgeDeviceStatusServiceImpl implements BadgeDeviceStatusService, I
|
||||
}
|
||||
|
||||
try {
|
||||
String key = OpsRedisKeyBuilder.badgeStatus(deviceId);
|
||||
String key = OpsRedisKeyBuilder.badgeStatus(deviceId);
|
||||
Map<Object, Object> map = stringRedisTemplate.opsForHash().entries(key);
|
||||
|
||||
if (map.isEmpty()) {
|
||||
@@ -232,7 +237,7 @@ public class BadgeDeviceStatusServiceImpl implements BadgeDeviceStatusService, I
|
||||
@Override
|
||||
public List<BadgeDeviceStatusDTO> listActiveBadges() {
|
||||
try {
|
||||
Set<String> keys = stringRedisTemplate.keys(OpsRedisKeyBuilder.badgeStatusPattern());
|
||||
Set<String> keys = stringRedisTemplate.keys(OpsRedisKeyBuilder.badgeStatusPattern());
|
||||
|
||||
if (keys == null || keys.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
@@ -240,7 +245,7 @@ public class BadgeDeviceStatusServiceImpl implements BadgeDeviceStatusService, I
|
||||
|
||||
return keys.stream()
|
||||
.map(key -> {
|
||||
String deviceIdStr = extractIdFromKey(key);
|
||||
String deviceIdStr = extractIdFromKey(key);
|
||||
return getBadgeStatus(Long.parseLong(deviceIdStr));
|
||||
})
|
||||
.filter(Objects::nonNull)
|
||||
@@ -263,7 +268,7 @@ public class BadgeDeviceStatusServiceImpl implements BadgeDeviceStatusService, I
|
||||
}
|
||||
|
||||
try {
|
||||
String key = OpsRedisKeyBuilder.badgeStatus(deviceId);
|
||||
String key = OpsRedisKeyBuilder.badgeStatus(deviceId);
|
||||
|
||||
// 获取当前状态
|
||||
Map<Object, Object> currentMap = stringRedisTemplate.opsForHash().entries(key);
|
||||
@@ -375,7 +380,7 @@ public class BadgeDeviceStatusServiceImpl implements BadgeDeviceStatusService, I
|
||||
}
|
||||
|
||||
try {
|
||||
String key = OpsRedisKeyBuilder.badgeStatus(deviceId);
|
||||
String key = OpsRedisKeyBuilder.badgeStatus(deviceId);
|
||||
stringRedisTemplate.opsForHash().put(key, "currentOpsOrderId", String.valueOf(orderId));
|
||||
log.debug("设置工牌设备当前工单: deviceId={}, orderId={}", deviceId, orderId);
|
||||
} catch (Exception e) {
|
||||
@@ -390,7 +395,7 @@ public class BadgeDeviceStatusServiceImpl implements BadgeDeviceStatusService, I
|
||||
}
|
||||
|
||||
try {
|
||||
String key = OpsRedisKeyBuilder.badgeStatus(deviceId);
|
||||
String key = OpsRedisKeyBuilder.badgeStatus(deviceId);
|
||||
|
||||
// 使用 Redis Pipeline 保证多个字段的原子性设置
|
||||
stringRedisTemplate.opsForHash().put(key, "currentOpsOrderId", String.valueOf(orderId));
|
||||
@@ -399,6 +404,15 @@ public class BadgeDeviceStatusServiceImpl implements BadgeDeviceStatusService, I
|
||||
}
|
||||
if (areaId != null) {
|
||||
stringRedisTemplate.opsForHash().put(key, "currentAreaId", String.valueOf(areaId));
|
||||
// 同步写入区域名称
|
||||
try {
|
||||
OpsBusAreaDO area = opsBusAreaMapper.selectById(areaId);
|
||||
if (area != null && area.getAreaName() != null) {
|
||||
stringRedisTemplate.opsForHash().put(key, "currentAreaName", area.getAreaName());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("查询区域名称失败: areaId={}", areaId, e);
|
||||
}
|
||||
}
|
||||
if (beaconMac != null) {
|
||||
stringRedisTemplate.opsForHash().put(key, "beaconMac", beaconMac);
|
||||
@@ -421,7 +435,7 @@ public class BadgeDeviceStatusServiceImpl implements BadgeDeviceStatusService, I
|
||||
}
|
||||
|
||||
try {
|
||||
String key = OpsRedisKeyBuilder.badgeStatus(deviceId);
|
||||
String key = OpsRedisKeyBuilder.badgeStatus(deviceId);
|
||||
stringRedisTemplate.opsForHash().put(key, "currentOrderStatus", orderStatus);
|
||||
log.debug("更新工单状态: deviceId={}, orderStatus={}", deviceId, orderStatus);
|
||||
} catch (Exception e) {
|
||||
@@ -436,7 +450,7 @@ public class BadgeDeviceStatusServiceImpl implements BadgeDeviceStatusService, I
|
||||
}
|
||||
|
||||
try {
|
||||
String key = OpsRedisKeyBuilder.badgeStatus(deviceId);
|
||||
String key = OpsRedisKeyBuilder.badgeStatus(deviceId);
|
||||
// 更新工单状态和信标MAC(orderId 和 areaId 已在 DISPATCHED 时设置,不需要重复)
|
||||
stringRedisTemplate.opsForHash().put(key, "currentOrderStatus", orderStatus);
|
||||
if (beaconMac != null) {
|
||||
@@ -456,7 +470,7 @@ public class BadgeDeviceStatusServiceImpl implements BadgeDeviceStatusService, I
|
||||
}
|
||||
|
||||
try {
|
||||
String key = OpsRedisKeyBuilder.badgeStatus(deviceId);
|
||||
String key = OpsRedisKeyBuilder.badgeStatus(deviceId);
|
||||
// 清除工单相关字段:currentOpsOrderId、currentOrderStatus、currentAreaId、currentAreaName、beaconMac
|
||||
stringRedisTemplate.opsForHash().delete(key, "currentOpsOrderId", "currentOrderStatus", "currentAreaId", "currentAreaName", "beaconMac");
|
||||
log.info("清除工牌设备当前工单: deviceId={}", deviceId);
|
||||
@@ -468,7 +482,7 @@ public class BadgeDeviceStatusServiceImpl implements BadgeDeviceStatusService, I
|
||||
@Override
|
||||
public List<BadgeDeviceStatusDTO> listBadgesWithCurrentOrder() {
|
||||
try {
|
||||
Set<String> keys = stringRedisTemplate.keys(OpsRedisKeyBuilder.badgeStatusPattern());
|
||||
Set<String> keys = stringRedisTemplate.keys(OpsRedisKeyBuilder.badgeStatusPattern());
|
||||
|
||||
if (keys == null || keys.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
@@ -476,7 +490,7 @@ public class BadgeDeviceStatusServiceImpl implements BadgeDeviceStatusService, I
|
||||
|
||||
return keys.stream()
|
||||
.map(key -> {
|
||||
String deviceIdStr = extractIdFromKey(key);
|
||||
String deviceIdStr = extractIdFromKey(key);
|
||||
return getBadgeStatus(Long.parseLong(deviceIdStr));
|
||||
})
|
||||
.filter(Objects::nonNull)
|
||||
@@ -519,7 +533,7 @@ public class BadgeDeviceStatusServiceImpl implements BadgeDeviceStatusService, I
|
||||
|
||||
// 直接写 status 字段,避免 updateBadgeStatus 内部回写已清除的 currentOpsOrderId
|
||||
if (deviceStatus.getStatus() != null && deviceStatus.getStatus() != BadgeDeviceStatusEnum.IDLE) {
|
||||
String key = OpsRedisKeyBuilder.badgeStatus(deviceId);
|
||||
String key = OpsRedisKeyBuilder.badgeStatus(deviceId);
|
||||
stringRedisTemplate.opsForHash().put(key, "status", BadgeDeviceStatusEnum.IDLE.getCode());
|
||||
stringRedisTemplate.opsForHash().put(key, "statusChangeTime", LocalDateTime.now().toString());
|
||||
}
|
||||
@@ -538,7 +552,7 @@ public class BadgeDeviceStatusServiceImpl implements BadgeDeviceStatusService, I
|
||||
}
|
||||
|
||||
try {
|
||||
String key = OpsRedisKeyBuilder.badgeStatus(deviceId);
|
||||
String key = OpsRedisKeyBuilder.badgeStatus(deviceId);
|
||||
|
||||
Map<String, String> statusMap = new HashMap<>();
|
||||
if (areaId != null) {
|
||||
@@ -567,7 +581,7 @@ public class BadgeDeviceStatusServiceImpl implements BadgeDeviceStatusService, I
|
||||
}
|
||||
|
||||
try {
|
||||
String key = OpsRedisKeyBuilder.badgeStatus(deviceId);
|
||||
String key = OpsRedisKeyBuilder.badgeStatus(deviceId);
|
||||
stringRedisTemplate.delete(key);
|
||||
log.info("删除工牌设备状态: deviceId={}", deviceId);
|
||||
} catch (Exception e) {
|
||||
@@ -578,7 +592,7 @@ public class BadgeDeviceStatusServiceImpl implements BadgeDeviceStatusService, I
|
||||
@Override
|
||||
public void clearOfflineBadges() {
|
||||
try {
|
||||
Set<String> keys = stringRedisTemplate.keys(OpsRedisKeyBuilder.badgeStatusPattern());
|
||||
Set<String> keys = stringRedisTemplate.keys(OpsRedisKeyBuilder.badgeStatusPattern());
|
||||
|
||||
if (keys == null || keys.isEmpty()) {
|
||||
return;
|
||||
@@ -654,9 +668,9 @@ public class BadgeDeviceStatusServiceImpl implements BadgeDeviceStatusService, I
|
||||
}
|
||||
}
|
||||
|
||||
private Integer getInteger(Object value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
private Integer getInteger(Object value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
if (value instanceof Integer) {
|
||||
return (Integer) value;
|
||||
@@ -665,12 +679,12 @@ public class BadgeDeviceStatusServiceImpl implements BadgeDeviceStatusService, I
|
||||
return Integer.parseInt(value.toString());
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private String extractIdFromKey(String key) {
|
||||
return key.substring(key.lastIndexOf(':') + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private String extractIdFromKey(String key) {
|
||||
return key.substring(key.lastIndexOf(':') + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 工牌设备离线事件
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
package com.viewsh.module.ops.environment.service.badge;
|
||||
|
||||
import com.viewsh.framework.common.pojo.CommonResult;
|
||||
import com.viewsh.module.iot.api.device.IotDeviceControlApi;
|
||||
import com.viewsh.module.iot.api.device.IotDevicePropertyQueryApi;
|
||||
import com.viewsh.module.iot.api.device.dto.IotDeviceServiceInvokeReqDTO;
|
||||
import com.viewsh.module.iot.api.trajectory.DeviceLocationDTO;
|
||||
import com.viewsh.module.iot.api.trajectory.TrajectoryStateApi;
|
||||
import com.viewsh.module.ops.api.badge.BadgeDeviceStatusDTO;
|
||||
import com.viewsh.module.ops.api.clean.BadgeRealtimeStatusRespDTO;
|
||||
import com.viewsh.module.ops.api.clean.BadgeStatusRespDTO;
|
||||
import com.viewsh.module.ops.dal.dataobject.area.OpsBusAreaDO;
|
||||
import com.viewsh.module.ops.dal.mysql.area.OpsBusAreaMapper;
|
||||
import com.viewsh.module.ops.environment.service.badge.dto.BadgeNotifyReqDTO;
|
||||
import com.viewsh.module.ops.service.area.AreaDeviceService;
|
||||
import jakarta.annotation.Resource;
|
||||
@@ -39,8 +45,22 @@ public class CleanBadgeServiceImpl implements CleanBadgeService {
|
||||
@Resource
|
||||
private IotDeviceControlApi iotDeviceControlApi;
|
||||
|
||||
@Resource
|
||||
private TrajectoryStateApi trajectoryStateApi;
|
||||
|
||||
@Resource
|
||||
private IotDevicePropertyQueryApi iotDevicePropertyQueryApi;
|
||||
|
||||
@Resource
|
||||
private OpsBusAreaMapper opsBusAreaMapper;
|
||||
|
||||
private static final String NOTIFY_IDENTIFIER = "NOTIFY";
|
||||
|
||||
/**
|
||||
* IoT 设备属性标识符:电池电量
|
||||
*/
|
||||
private static final String BATTERY_LEVEL_IDENTIFIER = "batteryLevel";
|
||||
|
||||
@Override
|
||||
public List<BadgeStatusRespDTO> getBadgeStatusList(Long areaId, String status) {
|
||||
try {
|
||||
@@ -83,28 +103,52 @@ public class CleanBadgeServiceImpl implements CleanBadgeService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取工牌实时状态详情
|
||||
* <p>
|
||||
* 聚合三个数据源:
|
||||
* 1. 工牌设备状态(Redis,本地)
|
||||
* 2. 工牌物理位置(IoT TrajectoryStateApi,RPC)
|
||||
* 3. 设备电量(IoT IotDevicePropertyQueryApi,RPC)
|
||||
*/
|
||||
@Override
|
||||
public BadgeRealtimeStatusRespDTO getBadgeRealtimeStatus(Long badgeId) {
|
||||
try {
|
||||
// 1. 获取工牌状态
|
||||
// 1. 获取工牌设备状态(Redis)
|
||||
BadgeDeviceStatusDTO status = badgeDeviceStatusService.getBadgeStatus(badgeId);
|
||||
if (status == null) {
|
||||
log.warn("[getBadgeRealtimeStatus] 工牌状态不存在: badgeId={}", badgeId);
|
||||
return null;
|
||||
}
|
||||
|
||||
// 2. 构建响应
|
||||
return BadgeRealtimeStatusRespDTO.builder()
|
||||
// 2. 查询工牌物理位置 + 电量
|
||||
DeviceLocationDTO location = queryPhysicalLocation(badgeId);
|
||||
Integer batteryLevel = queryBatteryLevel(badgeId);
|
||||
|
||||
// 3. 组装响应
|
||||
BadgeRealtimeStatusRespDTO.BadgeRealtimeStatusRespDTOBuilder builder = BadgeRealtimeStatusRespDTO.builder()
|
||||
.deviceId(status.getDeviceId())
|
||||
.deviceKey(status.getDeviceCode())
|
||||
.status(status.getStatusCode())
|
||||
.batteryLevel(status.getBatteryLevel())
|
||||
.lastHeartbeatTime(formatTimestamp(status.getLastHeartbeatTime()))
|
||||
.rssi(null) // RSSI 需要从 IoT 模块获取,暂不实现
|
||||
.isInArea(status.getCurrentAreaId() != null)
|
||||
.areaId(status.getCurrentAreaId())
|
||||
.areaName(status.getCurrentAreaName())
|
||||
.build();
|
||||
.batteryLevel(batteryLevel)
|
||||
.onlineTime(formatTimestamp(status.getLastHeartbeatTime()));
|
||||
|
||||
// 物理位置
|
||||
if (location != null && Boolean.TRUE.equals(location.getInArea())) {
|
||||
builder.isInArea(true)
|
||||
.areaId(location.getAreaId())
|
||||
.areaName(queryAreaNameById(location.getAreaId()));
|
||||
} else {
|
||||
builder.isInArea(false);
|
||||
}
|
||||
|
||||
// 当前工单信息
|
||||
builder.currentOrderId(status.getCurrentOpsOrderId())
|
||||
.currentOrderStatus(status.getCurrentOrderStatus())
|
||||
.orderAreaId(status.getCurrentAreaId())
|
||||
.orderAreaName(status.getCurrentAreaName());
|
||||
|
||||
return builder.build();
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("[getBadgeRealtimeStatus] 查询工牌实时状态失败: badgeId={}", badgeId, e);
|
||||
@@ -112,6 +156,61 @@ public class CleanBadgeServiceImpl implements CleanBadgeService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询工牌物理位置(来自 IoT 轨迹检测 RPC)
|
||||
*
|
||||
* @return 位置信息,查询失败返回 null
|
||||
*/
|
||||
private DeviceLocationDTO queryPhysicalLocation(Long badgeId) {
|
||||
try {
|
||||
CommonResult<DeviceLocationDTO> result = trajectoryStateApi.getCurrentLocation(badgeId);
|
||||
if (result != null && result.isSuccess()) {
|
||||
return result.getData();
|
||||
}
|
||||
return null;
|
||||
} catch (Exception e) {
|
||||
log.warn("[getBadgeRealtimeStatus] 查询工牌物理位置失败,降级为不在区域: badgeId={}", badgeId, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询电量(来自 IoT 设备属性 RPC)
|
||||
*
|
||||
* @return 电量百分比(0-100),查询失败返回 null
|
||||
*/
|
||||
private Integer queryBatteryLevel(Long badgeId) {
|
||||
try {
|
||||
CommonResult<Map<String, Object>> result = iotDevicePropertyQueryApi.getLatestProperties(badgeId);
|
||||
if (result != null && result.isSuccess() && result.getData() != null) {
|
||||
Object batteryObj = result.getData().get(BATTERY_LEVEL_IDENTIFIER);
|
||||
if (batteryObj instanceof Number) {
|
||||
return ((Number) batteryObj).intValue();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (Exception e) {
|
||||
log.warn("[getBadgeRealtimeStatus] 查询工牌电量失败: badgeId={}", badgeId, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据区域ID查询区域名称
|
||||
*/
|
||||
private String queryAreaNameById(Long areaId) {
|
||||
if (areaId == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
OpsBusAreaDO area = opsBusAreaMapper.selectById(areaId);
|
||||
return area != null ? area.getAreaName() : null;
|
||||
} catch (Exception e) {
|
||||
log.warn("[getBadgeRealtimeStatus] 查询区域名称失败: areaId={}", areaId, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void sendBadgeNotify(BadgeNotifyReqDTO req) {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.viewsh.module.ops.environment.service.trajectory;
|
||||
|
||||
import com.viewsh.framework.common.pojo.PageResult;
|
||||
import com.viewsh.module.ops.service.trajectory.dto.*;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 设备轨迹服务
|
||||
* <p>
|
||||
* 负责轨迹记录的创建和更新
|
||||
*
|
||||
* @author lzh
|
||||
*/
|
||||
public interface DeviceTrajectoryService {
|
||||
|
||||
/**
|
||||
* 记录设备进入区域
|
||||
*
|
||||
* @param deviceId 设备ID
|
||||
* @param deviceName 设备名称
|
||||
* @param nickname 设备备注名称
|
||||
* @param areaId 区域ID
|
||||
* @param beaconMac Beacon MAC
|
||||
* @param enterRssi 进入时 RSSI
|
||||
* @param enterTime 进入时间
|
||||
*/
|
||||
void recordEnter(Long deviceId, String deviceName, String nickname, Long areaId,
|
||||
String beaconMac, Integer enterRssi, LocalDateTime enterTime);
|
||||
|
||||
/**
|
||||
* 记录设备离开区域
|
||||
*
|
||||
* @param deviceId 设备ID
|
||||
* @param areaId 区域ID
|
||||
* @param leaveReason 离开原因
|
||||
* @param enterTimestamp 进入时间戳(毫秒),用于匹配记录
|
||||
* @param leaveTime 离开时间
|
||||
*/
|
||||
void recordLeave(Long deviceId, Long areaId, String leaveReason,
|
||||
Long enterTimestamp, LocalDateTime leaveTime);
|
||||
|
||||
/**
|
||||
* 分页查询轨迹记录
|
||||
*/
|
||||
PageResult<TrajectoryRespDTO> getTrajectoryPage(TrajectoryPageReqDTO req);
|
||||
|
||||
/**
|
||||
* 查询某设备某天的轨迹时间线
|
||||
*/
|
||||
List<TrajectoryRespDTO> getTimeline(Long deviceId, LocalDate date);
|
||||
|
||||
/**
|
||||
* 查询轨迹统计摘要(KPI 卡片)
|
||||
*
|
||||
* @param req 查询条件(date 必填,deviceId 可选)
|
||||
*/
|
||||
TrajectorySummaryDTO getSummary(TrajectoryStatsReqDTO req);
|
||||
|
||||
/**
|
||||
* 查询时段出入趋势(按小时聚合)
|
||||
*
|
||||
* @param req 查询条件(date 必填,deviceId 可选)
|
||||
*/
|
||||
List<HourlyTrendDTO> getHourlyTrend(TrajectoryStatsReqDTO req);
|
||||
|
||||
/**
|
||||
* 查询区域停留分布(按总停留时长降序)
|
||||
*
|
||||
* @param req 查询条件(date 必填,deviceId 可选)
|
||||
*/
|
||||
List<AreaStayStatsDTO> getAreaStayStats(TrajectoryStatsReqDTO req);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
package com.viewsh.module.ops.environment.service.trajectory;
|
||||
|
||||
import com.viewsh.framework.common.pojo.PageResult;
|
||||
import com.viewsh.framework.common.util.object.BeanUtils;
|
||||
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.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;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 设备轨迹服务实现
|
||||
*
|
||||
* @author lzh
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class DeviceTrajectoryServiceImpl implements DeviceTrajectoryService {
|
||||
|
||||
@Resource
|
||||
private OpsDeviceTrajectoryMapper trajectoryMapper;
|
||||
|
||||
@Resource
|
||||
private OpsBusAreaMapper areaMapper;
|
||||
|
||||
@Resource
|
||||
private AreaPathBuilder areaPathBuilder;
|
||||
|
||||
// ==================== 写入方法 ====================
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void recordEnter(Long deviceId, String deviceName, String nickname, Long areaId,
|
||||
String beaconMac, Integer enterRssi, LocalDateTime enterTime) {
|
||||
// 使用 SELECT ... FOR UPDATE 防止并发创建重复记录
|
||||
OpsDeviceTrajectoryDO openRecord = trajectoryMapper.selectOpenRecordForUpdate(deviceId, areaId);
|
||||
if (openRecord != null) {
|
||||
log.warn("[Trajectory] 设备已有未关闭的轨迹记录,跳过创建:deviceId={}, areaId={}, existingId={}",
|
||||
deviceId, areaId, openRecord.getId());
|
||||
return;
|
||||
}
|
||||
|
||||
OpsDeviceTrajectoryDO record = OpsDeviceTrajectoryDO.builder()
|
||||
.deviceId(deviceId)
|
||||
.deviceName(deviceName)
|
||||
.nickname(nickname)
|
||||
.areaId(areaId)
|
||||
.beaconMac(beaconMac)
|
||||
.enterRssi(enterRssi)
|
||||
.enterTime(enterTime)
|
||||
.build();
|
||||
|
||||
// 填充区域名称冗余字段
|
||||
fillAreaName(record);
|
||||
|
||||
trajectoryMapper.insert(record);
|
||||
log.info("[Trajectory] 创建轨迹记录:id={}, deviceId={}, areaId={}, enterTime={}",
|
||||
record.getId(), deviceId, areaId, enterTime);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void recordLeave(Long deviceId, Long areaId, String leaveReason,
|
||||
Long enterTimestamp, LocalDateTime leaveTime) {
|
||||
// 优先使用 enterTimestamp 精确匹配,避免关闭错误的记录
|
||||
OpsDeviceTrajectoryDO record = null;
|
||||
if (enterTimestamp != null) {
|
||||
LocalDateTime enterTime = LocalDateTime.ofInstant(
|
||||
Instant.ofEpochMilli(enterTimestamp), ZoneId.systemDefault());
|
||||
record = trajectoryMapper.selectOpenRecordByEnterTimeForUpdate(deviceId, areaId, enterTime);
|
||||
}
|
||||
// 降级:按 deviceId + areaId 查询最近的未关闭记录
|
||||
if (record == null) {
|
||||
record = trajectoryMapper.selectOpenRecordForUpdate(deviceId, areaId);
|
||||
}
|
||||
if (record == null) {
|
||||
log.warn("[Trajectory] 未找到匹配的轨迹记录,跳过更新:deviceId={}, areaId={}, enterTimestamp={}",
|
||||
deviceId, areaId, enterTimestamp);
|
||||
return;
|
||||
}
|
||||
|
||||
int durationSeconds = 0;
|
||||
if (record.getEnterTime() != null && leaveTime != null) {
|
||||
durationSeconds = (int) Duration.between(record.getEnterTime(), leaveTime).getSeconds();
|
||||
if (durationSeconds < 0) {
|
||||
durationSeconds = 0;
|
||||
}
|
||||
}
|
||||
|
||||
record.setLeaveTime(leaveTime);
|
||||
record.setDurationSeconds(durationSeconds);
|
||||
record.setLeaveReason(leaveReason);
|
||||
|
||||
trajectoryMapper.updateById(record);
|
||||
log.info("[Trajectory] 更新轨迹记录(离开):id={}, deviceId={}, areaId={}, duration={}s, reason={}",
|
||||
record.getId(), deviceId, areaId, durationSeconds, leaveReason);
|
||||
}
|
||||
|
||||
// ==================== 查询方法 ====================
|
||||
|
||||
@Override
|
||||
public PageResult<TrajectoryRespDTO> getTrajectoryPage(TrajectoryPageReqDTO req) {
|
||||
PageResult<OpsDeviceTrajectoryDO> pageResult = trajectoryMapper.selectPage(req);
|
||||
PageResult<TrajectoryRespDTO> result = BeanUtils.toBean(pageResult, TrajectoryRespDTO.class);
|
||||
enrichWithAreaInfo(result.getList());
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<TrajectoryRespDTO> getTimeline(Long deviceId, LocalDate date) {
|
||||
List<OpsDeviceTrajectoryDO> list = trajectoryMapper.selectByDateAndDevice(date, deviceId);
|
||||
List<TrajectoryRespDTO> result = BeanUtils.toBean(list, TrajectoryRespDTO.class);
|
||||
enrichWithAreaInfo(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public TrajectorySummaryDTO getSummary(TrajectoryStatsReqDTO req) {
|
||||
List<OpsDeviceTrajectoryDO> list = trajectoryMapper.selectByDateAndDevice(req.getDate(), req.getDeviceId());
|
||||
if (list.isEmpty()) {
|
||||
return TrajectorySummaryDTO.builder()
|
||||
.workDurationSeconds(0L)
|
||||
.coveredAreaCount(0L)
|
||||
.totalEvents(0L)
|
||||
.avgStaySeconds(0L)
|
||||
.build();
|
||||
}
|
||||
|
||||
long totalEvents = list.size();
|
||||
long coveredAreaCount = list.stream()
|
||||
.map(OpsDeviceTrajectoryDO::getAreaId)
|
||||
.distinct()
|
||||
.count();
|
||||
|
||||
List<Integer> durations = list.stream()
|
||||
.map(OpsDeviceTrajectoryDO::getDurationSeconds)
|
||||
.filter(Objects::nonNull)
|
||||
.toList();
|
||||
|
||||
long workDuration = durations.stream().mapToLong(Integer::longValue).sum();
|
||||
long avgStay = durations.isEmpty() ? 0 : workDuration / durations.size();
|
||||
|
||||
return TrajectorySummaryDTO.builder()
|
||||
.workDurationSeconds(workDuration)
|
||||
.coveredAreaCount(coveredAreaCount)
|
||||
.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());
|
||||
}
|
||||
|
||||
// ==================== 内部方法 ====================
|
||||
|
||||
/**
|
||||
* 填充单条记录的区域名称
|
||||
*/
|
||||
private void fillAreaName(OpsDeviceTrajectoryDO record) {
|
||||
if (record.getAreaId() == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
OpsBusAreaDO area = areaMapper.selectById(record.getAreaId());
|
||||
if (area != null) {
|
||||
record.setAreaName(area.getAreaName());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("[Trajectory] 查询区域名称失败:areaId={}", record.getAreaId(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量填充轨迹记录的区域信息(areaName、fullAreaName)
|
||||
*/
|
||||
private void enrichWithAreaInfo(List<TrajectoryRespDTO> list) {
|
||||
if (list == null || list.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 收集所有 areaId
|
||||
Set<Long> areaIds = list.stream()
|
||||
.map(TrajectoryRespDTO::getAreaId)
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
if (areaIds.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 批量查询区域 + 一次性构建 fullAreaName
|
||||
List<OpsBusAreaDO> areas = areaMapper.selectBatchIds(areaIds);
|
||||
Map<Long, OpsBusAreaDO> areaMap = areas.stream()
|
||||
.collect(Collectors.toMap(OpsBusAreaDO::getId, a -> a, (a, b) -> a));
|
||||
Map<Long, String> fullAreaNameMap = areaPathBuilder.buildPaths(areas);
|
||||
|
||||
// 填充每条记录
|
||||
for (TrajectoryRespDTO dto : list) {
|
||||
if (dto.getAreaId() == null) {
|
||||
continue;
|
||||
}
|
||||
OpsBusAreaDO area = areaMap.get(dto.getAreaId());
|
||||
if (area == null) {
|
||||
continue;
|
||||
}
|
||||
dto.setAreaName(area.getAreaName());
|
||||
dto.setFullAreaName(fullAreaNameMap.get(dto.getAreaId()));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user