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

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

View File

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

View File

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

View File

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

View File

@@ -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 {
/**
* 事件IDUUID用于幂等性控制
*/
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;
}

View File

@@ -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 {
/**
* 事件IDUUID用于幂等性控制
*/
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;
}

View File

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

View File

@@ -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 在线/离线状态对账

View File

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

View File

@@ -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);
// 更新工单状态和信标MACorderId 和 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);
}
/**
* 工牌设备离线事件

View File

@@ -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 TrajectoryStateApiRPC
* 3. 设备电量IoT IotDevicePropertyQueryApiRPC
*/
@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 {

View File

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

View File

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