refactor(ops): simplify badge status management by removing redundant heartbeat logic
Some checks failed
Java CI with Maven / build (11) (push) Has been cancelled
Java CI with Maven / build (17) (push) Has been cancelled
Java CI with Maven / build (8) (push) Has been cancelled

This commit is contained in:
lzh
2026-01-29 13:04:19 +08:00
parent da19b87377
commit 44c7d7b9f5
3 changed files with 185 additions and 345 deletions

View File

@@ -24,250 +24,202 @@ import java.util.List;
*/
public interface BadgeDeviceStatusService {
// ==================== 状态管理 ====================
// ==================== 状态管理 ====================
/**
* 更新工牌设备状态
* <p>
* 状态转换会记录状态变更时间和操作原因
*
* @param deviceId 设备ID
* @param status 目标状态
* @param operatorId 操作人ID可为null表示系统操作
* @param reason 状态变更原因
*/
void updateBadgeStatus(Long deviceId, BadgeDeviceStatusEnum status, Long operatorId, String reason);
/**
* 更新工牌设备状态
* <p>
* 状态转换会记录状态变更时间和操作原因
*
* @param deviceId 设备ID
* @param status 目标状态
* @param operatorId 操作人ID可为null表示系统操作
* @param reason 状态变更原因
*/
void updateBadgeStatus(Long deviceId, BadgeDeviceStatusEnum status, Long operatorId, String reason);
/**
* 批量更新工牌设备状态
*
* @param deviceIds 设备ID列表
* @param status 目标状态
* @param operatorId 操作人ID
* @param reason 状态变更原因
*/
void batchUpdateBadgeStatus(List<Long> deviceIds, BadgeDeviceStatusEnum status, Long operatorId, String reason);
/**
* 批量更新工牌设备状态
*
* @param deviceIds 设备ID列表
* @param status 目标状态
* @param operatorId 操作人ID
* @param reason 状态变更原因
*/
void batchUpdateBadgeStatus(List<Long> deviceIds, BadgeDeviceStatusEnum status, Long operatorId, String reason);
// ==================== 状态查询 ====================
// ==================== 状态查询 ====================
/**
* 获取工牌设备状态
*
* @param deviceId 设备ID
* @return 设备状态DTO不存在返回null
*/
BadgeDeviceStatusDTO getBadgeStatus(Long deviceId);
/**
* 获取工牌设备状态
*
* @param deviceId 设备ID
* @return 设备状态DTO不存在返回null
*/
BadgeDeviceStatusDTO getBadgeStatus(Long deviceId);
/**
* 批量获取工牌设备状态
*
* @param deviceIds 设备ID列表
* @return 设备状态DTO列表
*/
List<BadgeDeviceStatusDTO> batchGetBadgeStatus(List<Long> deviceIds);
/**
* 批量获取工牌设备状态
*
* @param deviceIds 设备ID列表
* @return 设备状态DTO列表
*/
List<BadgeDeviceStatusDTO> batchGetBadgeStatus(List<Long> deviceIds);
/**
* 获取指定区域的工牌设备列表
* <p>
* 只返回非 OFFLINE 状态的设备
*
* @param areaId 区域ID
* @return 设备状态DTO列表
*/
List<BadgeDeviceStatusDTO> listBadgesByArea(Long areaId);
/**
* 获取指定区域的工牌设备列表
* <p>
* 只返回非 OFFLINE 状态的设备
*
* @param areaId 区域ID
* @return 设备状态DTO列表
*/
List<BadgeDeviceStatusDTO> listBadgesByArea(Long areaId);
/**
* 获取可接单的工牌设备IDLE 状态)
*
* @param areaId 区域ID
* @return 可接单设备列表
*/
List<BadgeDeviceStatusDTO> listAvailableBadges(Long areaId);
/**
* 获取可接单的工牌设备IDLE 状态)
*
* @param areaId 区域ID
* @return 可接单设备列表
*/
List<BadgeDeviceStatusDTO> listAvailableBadges(Long areaId);
/**
* 获取所有活跃的工牌设备非OFFLINE状态
*
* @return 活跃设备列表
*/
List<BadgeDeviceStatusDTO> listActiveBadges();
/**
* 获取所有活跃的工牌设备非OFFLINE状态
*
* @return 活跃设备列表
*/
List<BadgeDeviceStatusDTO> listActiveBadges();
// ==================== 心跳处理 ====================
// ==================== 状态更新 (IoT 事件驱动) ====================
/**
* 处理工牌设备心跳
* <p>
* 更新最后心跳时间和电量
* <ul>
* <li>如果设备之前为 OFFLINE为 IDLE</li>
* <li>如果设备已存在,更新心跳时间和电量</li>
* <li>如果设备不存在,创建新记录(状态为 IDLE</li>
* </ul>
*
* @param deviceId 设备ID
* @param deviceCode 设备编码
* @param batteryLevel 电量0-100
*/
void handleHeartbeat(Long deviceId, String deviceCode, Integer batteryLevel);
/**
* 更新工牌设备在线状态
* <p>
* 用于处理 IoT 设备状态变更事件和定时对账
* <ul>
* <li>设备上线:创建或更新状态记录,状态设为 IDLE</li>
* <li>设备离线:更新状态记录,状态设为 OFFLINE</li>
* </ul>
*
* @param deviceId 设备ID
* @param deviceCode 设备编码
* @param areaId 区域ID可为null
* @param status 目标状态IDLE 或 OFFLINE
* @param reason 状态变更原因
*/
void updateBadgeOnlineStatus(Long deviceId, String deviceCode, Long areaId,
BadgeDeviceStatusEnum status, String reason);
/**
* 处理工牌设备心跳(带区域信息)
*
* @param deviceId 设备ID
* @param deviceCode 设备编码
* @param batteryLevel 电量0-100
* @param areaId 当前所在区域ID
* @param areaName 当前所在区域名称
*/
void handleHeartbeatWithArea(Long deviceId, String deviceCode, Integer batteryLevel,
Long areaId, String areaName);
// ==================== 在线状态检查 ====================
/**
* 更新工牌设备在线状态
* <p>
* 用于处理 IoT 设备状态变更事件和定时对账:
* <ul>
* <li>设备上线:创建或更新状态记录,状态设为 IDLE</li>
* <li>设备离线:更新状态记录,状态设为 OFFLINE</li>
* </ul>
* <p>
* 与 handleHeartbeat 区别:
* - handleHeartbeat处理位置上报心跳包含电量等详细信息
* - updateBadgeOnlineStatus仅处理在线/离线状态变更
*
* @param deviceId 设备ID
* @param deviceCode 设备编码
* @param areaId 区域ID可为null
* @param status 目标状态IDLE 或 OFFLINE
* @param reason 状态变更原因
*/
void updateBadgeOnlineStatus(Long deviceId, String deviceCode, Long areaId,
BadgeDeviceStatusEnum status, String reason);
/**
* 检查工牌设备是否在线非OFFLINE状态
*
* @param deviceId 设备ID
* @return 是否在线
*/
boolean isBadgeOnline(Long deviceId);
// ==================== 在线状态检查 ====================
// ==================== 工单关联 ====================
/**
* 检查工牌设备是否在线非OFFLINE状态
*
* @param deviceId 设备ID
* @return 是否在线
*/
boolean isBadgeOnline(Long deviceId);
/**
* 设置当前工单
*
* @param deviceId 设备ID
* @param orderId 工单ID
*/
void setCurrentOrder(Long deviceId, Long orderId);
/**
* 检查工牌设备心跳是否超时
*
* @param deviceId 设备ID
* @param thresholdMinutes 超时阈值(分钟)
* @return 是否超时
*/
boolean isHeartbeatTimeout(Long deviceId, int thresholdMinutes);
/**
* 设置当前工单详细信息(原子操作,并发安全)
* <p>
* 一次性设置工单ID、工单状态、区域ID、信标MAC
* 使用 Redis Hash 的 hset() 操作,保证原子性
*
* @param deviceId 设备ID
* @param orderId 工单ID
* @param orderStatus 工单状态DISPATCHED/ARRIVED/PAUSED
* @param areaId 区域ID
* @param beaconMac 信标MAC地址可为null
*/
void setCurrentOrderInfo(Long deviceId, Long orderId, String orderStatus, Long areaId, String beaconMac);
/**
* 检查心跳超时并将超时设备设为OFFLINE
* <p>
* 定时任务调用默认超时时间为30分钟
*/
void checkAndMarkOfflineDevices();
/**
* 更新工单状态
*
* @param deviceId 设备ID
* @param orderStatus 工单状态
*/
void updateOrderStatus(Long deviceId, String orderStatus);
// ==================== 工单关联 ====================
/**
* 清除当前工单包括工单ID、工单状态、信标MAC
*
* @param deviceId 设备ID
*/
void clearCurrentOrder(Long deviceId);
/**
* 设置当前工单
*
* @param deviceId 设备ID
* @param orderId 工单ID
*/
void setCurrentOrder(Long deviceId, Long orderId);
/**
* 获取当前工单的设备列表
*
* @return 有工单的设备列表
*/
List<BadgeDeviceStatusDTO> listBadgesWithCurrentOrder();
/**
* 设置当前工单详细信息(原子操作,并发安全)
* <p>
* 一次性设置工单ID、工单状态、区域ID、信标MAC
* 使用 Redis Hash 的 hset() 操作,保证原子性
*
* @param deviceId 设备ID
* @param orderId 工单ID
* @param orderStatus 工单状态DISPATCHED/ARRIVED/PAUSED
* @param areaId 区域ID
* @param beaconMac 信标MAC地址可为null
*/
void setCurrentOrderInfo(Long deviceId, Long orderId, String orderStatus, Long areaId, String beaconMac);
// ==================== 区域管理 ====================
/**
* 更新工单状态
*
* @param deviceId 设备ID
* @param orderStatus 工单状态
*/
void updateOrderStatus(Long deviceId, String orderStatus);
/**
* 更新工牌设备所在区域
*
* @param deviceId 设备ID
* @param areaId 区域ID
* @param areaName 区域名称
*/
void updateBadgeArea(Long deviceId, Long areaId, String areaName);
/**
* 清除当前工单包括工单ID、工单状态、信标MAC
*
* @param deviceId 设备ID
*/
void clearCurrentOrder(Long deviceId);
/**
* 初始化区域设备索引
* <p>
* 从 ops_area_device_relation 表加载 BADGE 类型的设备,
* 建立区域到设备的索引关系
*/
void initAreaDeviceIndex();
/**
* 获取当前有工单的设备列表
*
* @return 有工单的设备列表
*/
List<BadgeDeviceStatusDTO> listBadgesWithCurrentOrder();
/**
* 刷新区域设备索引
* <p>
* 重新从数据库加载区域设备关系
*/
void refreshAreaDeviceIndex();
// ==================== 区域管理 ====================
/**
* 将设备添加到区域索引
*
* @param deviceId 设备ID
* @param areaId 区域ID
*/
void addToAreaIndex(Long deviceId, Long areaId);
/**
* 更新工牌设备所在区域
*
* @param deviceId 设备ID
* @param areaId 区域ID
* @param areaName 区域名称
*/
void updateBadgeArea(Long deviceId, Long areaId, String areaName);
/**
* 从区域索引移除设备
*
* @param deviceId 设备ID
* @param areaId 区域ID
*/
void removeFromAreaIndex(Long deviceId, Long areaId);
/**
* 初始化区域设备索引
* <p>
* 从 ops_area_device_relation 表加载 BADGE 类型的设备,
* 建立区域到设备的索引关系
*/
void initAreaDeviceIndex();
// ==================== 设备管理 ====================
/**
* 刷新区域设备索引
* <p>
* 重新从数据库加载区域设备关系
*/
void refreshAreaDeviceIndex();
/**
* 删除工牌设备状态
*
* @param deviceId 设备ID
*/
void deleteBadgeStatus(Long deviceId);
/**
* 将设备添加到区域索引
*
* @param deviceId 设备ID
* @param areaId 区域ID
*/
void addToAreaIndex(Long deviceId, Long areaId);
/**
* 从区域索引移除设备
*
* @param deviceId 设备ID
* @param areaId 区域ID
*/
void removeFromAreaIndex(Long deviceId, Long areaId);
// ==================== 设备管理 ====================
/**
* 删除工牌设备状态
*
* @param deviceId 设备ID
*/
void deleteBadgeStatus(Long deviceId);
/**
* 清理所有离线设备的状态
*/
void clearOfflineBadges();
/**
* 清理所有离线设备的状态
*/
void clearOfflineBadges();
}

View File

@@ -7,7 +7,7 @@ import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
@@ -245,80 +245,7 @@ public class BadgeDeviceStatusServiceImpl implements BadgeDeviceStatusService, I
}
}
// ==================== 心跳处理 ====================
@Override
public void handleHeartbeat(Long deviceId, String deviceCode, Integer batteryLevel) {
handleHeartbeatWithArea(deviceId, deviceCode, batteryLevel, null, null);
}
@Override
public void handleHeartbeatWithArea(Long deviceId, String deviceCode, Integer batteryLevel,
Long areaId, String areaName) {
if (deviceId == null) {
return;
}
try {
String key = BADGE_STATUS_KEY_PREFIX + deviceId;
Long now = System.currentTimeMillis();
// 获取当前状态
Map<Object, Object> currentMap = redisTemplate.opsForHash().entries(key);
BadgeDeviceStatusEnum currentStatus = null;
if (!currentMap.isEmpty()) {
String statusStr = (String) currentMap.get("status");
currentStatus = BadgeDeviceStatusEnum.fromCode(statusStr);
}
// 如果之前是 OFFLINE转为 IDLE
// 如果当前状态为空,初始化为 IDLE
BadgeDeviceStatusEnum newStatus = currentStatus;
if (currentStatus == null) {
newStatus = BadgeDeviceStatusEnum.IDLE;
} else if (currentStatus == BadgeDeviceStatusEnum.OFFLINE) {
// 如果已标记为离线,心跳不再自动复活设备
// 必须依赖 IoT 上线事件或定时对账 Job 来恢复在线状态
// 这样做是为了防止因离线事件和心跳包乱序(幽灵心跳)导致的“假在线”问题
log.debug("设备处于离线状态但收到心跳,保持离线状态: deviceId={}", deviceId);
newStatus = BadgeDeviceStatusEnum.OFFLINE;
}
// 更新状态
Map<String, Object> statusMap = new HashMap<>();
statusMap.put("deviceId", deviceId);
statusMap.put("deviceCode", deviceCode != null ? deviceCode : currentMap.get("deviceCode"));
statusMap.put("status", newStatus.getCode());
statusMap.put("batteryLevel", batteryLevel != null ? batteryLevel : currentMap.get("batteryLevel"));
statusMap.put("lastHeartbeatTime", now);
statusMap.put("statusChangeTime", LocalDateTime.now().toString());
// 更新区域信息
if (areaId != null) {
statusMap.put("currentAreaId", areaId);
statusMap.put("currentAreaName", areaName);
// 更新区域索引
addToAreaIndex(deviceId, areaId);
} else {
statusMap.put("currentAreaId", currentMap.getOrDefault("currentAreaId", null));
statusMap.put("currentAreaName", currentMap.getOrDefault("currentAreaName", null));
}
// 保持当前工单相关字段
statusMap.put("currentOpsOrderId", currentMap.get("currentOpsOrderId"));
statusMap.put("currentOrderStatus", currentMap.get("currentOrderStatus"));
statusMap.put("beaconMac", currentMap.get("beaconMac"));
redisTemplate.opsForHash().putAll(key, statusMap);
redisTemplate.expire(key, STATUS_EXPIRE_HOURS, TimeUnit.HOURS);
log.debug("处理工牌设备心跳: deviceId={}, deviceCode={}, batteryLevel={}, status={}",
deviceId, deviceCode, batteryLevel, newStatus);
} catch (Exception e) {
log.error("处理工牌设备心跳失败: deviceId={}", deviceId, e);
}
}
// ==================== 状态更新 (IoT 事件驱动) ====================
@Override
public void updateBadgeOnlineStatus(Long deviceId, String deviceCode, Long areaId,
@@ -342,7 +269,7 @@ public class BadgeDeviceStatusServiceImpl implements BadgeDeviceStatusService, I
statusMap.put("statusChangeTime", LocalDateTime.now().toString());
statusMap.put("statusChangeReason", reason);
// 如果是上线(非 OFFLINE 状态),更新心跳时间
// 如果是上线(非 OFFLINE 状态),更新心跳时间(此处作为活跃时间使用)
if (status.isActive()) {
statusMap.put("lastHeartbeatTime", now);
}
@@ -389,49 +316,12 @@ public class BadgeDeviceStatusServiceImpl implements BadgeDeviceStatusService, I
}
}
// ==================== 在线状态检查 ====================
@Override
public boolean isBadgeOnline(Long deviceId) {
BadgeDeviceStatusDTO status = getBadgeStatus(deviceId);
return status != null && status.isOnline();
}
@Override
public boolean isHeartbeatTimeout(Long deviceId, int thresholdMinutes) {
BadgeDeviceStatusDTO status = getBadgeStatus(deviceId);
return status == null || status.isHeartbeatTimeout(thresholdMinutes);
}
@Override
@Scheduled(cron = "0 */5 * * * ?")
public void checkAndMarkOfflineDevices() {
try {
List<BadgeDeviceStatusDTO> activeBadges = listActiveBadges();
long thresholdMillis = System.currentTimeMillis() - (HEARTBEAT_TIMEOUT_MINUTES * 60L * 1000L);
List<Long> offlineDeviceIds = new ArrayList<>();
for (BadgeDeviceStatusDTO device : activeBadges) {
if (device.getLastHeartbeatTime() == null ||
device.getLastHeartbeatTime() < thresholdMillis) {
updateBadgeStatus(device.getDeviceId(), BadgeDeviceStatusEnum.OFFLINE, null, "心跳超时");
offlineDeviceIds.add(device.getDeviceId());
}
}
if (!offlineDeviceIds.isEmpty()) {
log.info("标记心跳超时设备为离线: count={}, deviceIds={}",
offlineDeviceIds.size(), offlineDeviceIds);
}
} catch (Exception e) {
log.error("检查心跳超时失败", e);
}
}
// ==================== 工单关联 ====================
@Override
public void setCurrentOrder(Long deviceId, Long orderId) {
if (deviceId == null || orderId == null) {

View File

@@ -32,7 +32,7 @@ import static org.mockito.Mockito.*;
*/
@Slf4j
@SpringJUnitConfig(classes = BadgeDispatchTestConfig.class)
@TestExecutionListeners({DependencyInjectionTestExecutionListener.class})
@TestExecutionListeners({ DependencyInjectionTestExecutionListener.class })
public class BadgeDeviceDispatchTest {
@Resource
@@ -114,32 +114,30 @@ public class BadgeDeviceDispatchTest {
log.info("测试:心跳调用");
log.info("========================================");
// 模拟心跳调用
badgeDeviceStatusService.handleHeartbeatWithArea(
// 模拟状态更新调用
badgeDeviceStatusService.updateBadgeOnlineStatus(
TEST_DEVICE_ID,
TEST_DEVICE_CODE,
75,
TEST_AREA_ID,
"A座2楼男卫"
);
BadgeDeviceStatusEnum.IDLE,
"测试心跳");
// 验证调用
verify(badgeDeviceStatusService).handleHeartbeatWithArea(
verify(badgeDeviceStatusService).updateBadgeOnlineStatus(
eq(TEST_DEVICE_ID),
eq(TEST_DEVICE_CODE),
eq(75),
eq(TEST_AREA_ID),
eq("A座2楼男卫")
);
eq(BadgeDeviceStatusEnum.IDLE),
eq("测试心跳"));
// Mock getBadgeStatus return
BadgeDeviceStatusDTO mockStatus = new BadgeDeviceStatusDTO();
mockStatus.setStatus(BadgeDeviceStatusEnum.IDLE);
mockStatus.setBatteryLevel(75);
mockStatus.setCurrentAreaName("A座2楼男卫");
when(badgeDeviceStatusService.getBadgeStatus(TEST_DEVICE_ID)).thenReturn(mockStatus);
BadgeDeviceStatusDTO status = badgeDeviceStatusService.getBadgeStatus(TEST_DEVICE_ID);
assertNotNull(status);
assertEquals(BadgeDeviceStatusEnum.IDLE, status.getStatus());