feat(ops): 事件日志增强与空闲设备待办检查
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

- 实现空闲设备待办工单检查定时任务

- 修复事件日志 orderId 关联 (使用 targetId)

- 增强语音播报和工单事件的日志完整性
This commit is contained in:
lzh
2026-01-30 12:03:24 +08:00
parent 80278f64f6
commit 28b9a32cb6
8 changed files with 285 additions and 50 deletions

View File

@@ -36,12 +36,7 @@ import java.util.concurrent.TimeUnit;
*/
@Slf4j
@Component
@RocketMQMessageListener(
topic = "ops-order-audit",
consumerGroup = "ops-clean-order-audit-group",
consumeMode = ConsumeMode.CONCURRENTLY,
selectorExpression = "*"
)
@RocketMQMessageListener(topic = "ops-order-audit", consumerGroup = "ops-clean-order-audit-group", consumeMode = ConsumeMode.CONCURRENTLY, selectorExpression = "*")
public class CleanOrderAuditEventHandler implements RocketMQListener<String> {
/**
@@ -148,7 +143,7 @@ public class CleanOrderAuditEventHandler implements RocketMQListener<String> {
return;
}
sendTts(event.getDeviceId(), ttsText);
sendTts(event.getDeviceId(), ttsText, event.getOrderId());
}
/**
@@ -187,15 +182,16 @@ public class CleanOrderAuditEventHandler implements RocketMQListener<String> {
String ttsText = CleanNotificationConstants.VoiceBuilder.buildQuery(currentOrderTitle, pendingCount.intValue());
// 4. 下发 TTS
sendTts(deviceId, ttsText);
Long orderId = currentOrder != null ? currentOrder.getId() : null;
sendTts(deviceId, ttsText, orderId);
}
/**
* 下发 TTS 语音播报
*/
private void sendTts(Long deviceId, String text) {
private void sendTts(Long deviceId, String text, Long orderId) {
try {
voiceBroadcastService.broadcast(deviceId, text);
voiceBroadcastService.broadcast(deviceId, text, orderId);
log.info("[CleanOrderAuditEventHandler] TTS 下发成功: deviceId={}, text={}", deviceId, text);
} catch (Exception e) {
log.error("[CleanOrderAuditEventHandler] TTS 下发异常: deviceId={}", deviceId, e);

View File

@@ -34,12 +34,7 @@ import java.util.concurrent.TimeUnit;
*/
@Slf4j
@Component
@RocketMQMessageListener(
topic = "ops-order-create",
consumerGroup = "ops-clean-order-create-group",
consumeMode = ConsumeMode.CONCURRENTLY,
selectorExpression = "*"
)
@RocketMQMessageListener(topic = "ops-order-create", consumerGroup = "ops-clean-order-create-group", consumeMode = ConsumeMode.CONCURRENTLY, selectorExpression = "*")
public class CleanOrderCreateEventHandler implements RocketMQListener<String> {
/**
@@ -102,8 +97,8 @@ public class CleanOrderCreateEventHandler implements RocketMQListener<String> {
createReq.setSourceType("TRAFFIC"); // 系统触发
createReq.setTitle(generateOrderTitle(event));
createReq.setDescription(generateOrderDescription(event));
createReq.setPriority(PriorityEnum.fromPriority(event.getPriority() != null ?
event.getPriority() : 2).getPriority());
createReq.setPriority(
PriorityEnum.fromPriority(event.getPriority() != null ? event.getPriority() : 2).getPriority());
createReq.setAreaId(event.getAreaId());
// 扩展字段
@@ -133,7 +128,8 @@ public class CleanOrderCreateEventHandler implements RocketMQListener<String> {
/**
* 记录工单创建业务日志
*/
private void recordOrderCreatedLog(CleanOrderCreateEventDTO event, Long orderId, CleanOrderAutoCreateReqDTO createReq) {
private void recordOrderCreatedLog(CleanOrderCreateEventDTO event, Long orderId,
CleanOrderAutoCreateReqDTO createReq) {
try {
// 确定事件域
EventDomain domain = determineDomain(event.getTriggerSource());

View File

@@ -28,13 +28,41 @@ import org.springframework.stereotype.Component;
* <p>
* 状态处理:
* <table>
* <tr><th>状态</th><th>设备状态</th><th>工单关联</th></tr>
* <tr><td>DISPATCHED</td><td>BUSY</td><td>设置</td></tr>
* <tr><td>CONFIRMED</td><td>保持BUSY</td><td>设置</td></tr>
* <tr><td>ARRIVED</td><td>保持BUSY</td><td>设置完整信息</td></tr>
* <tr><td>PAUSED</td><td>PAUSED</td><td>保持</td></tr>
* <tr><td>COMPLETED</td><td>检查等待任务</td><td>清除</td></tr>
* <tr><td>CANCELLED</td><td>IDLE</td><td>清除</td></tr>
* <tr>
* <th>状态</th>
* <th>设备状态</th>
* <th>工单关联</th>
* </tr>
* <tr>
* <td>DISPATCHED</td>
* <td>BUSY</td>
* <td>设置</td>
* </tr>
* <tr>
* <td>CONFIRMED</td>
* <td>保持BUSY</td>
* <td>设置</td>
* </tr>
* <tr>
* <td>ARRIVED</td>
* <td>保持BUSY</td>
* <td>设置完整信息</td>
* </tr>
* <tr>
* <td>PAUSED</td>
* <td>PAUSED</td>
* <td>保持</td>
* </tr>
* <tr>
* <td>COMPLETED</td>
* <td>检查等待任务</td>
* <td>清除</td>
* </tr>
* <tr>
* <td>CANCELLED</td>
* <td>IDLE</td>
* <td>清除</td>
* </tr>
* </table>
*
* @author lzh
@@ -99,14 +127,16 @@ public class BadgeDeviceStatusEventListener {
/**
* 根据工单状态更新设备工牌关联
*/
private void handleOrderStatusTransition(Long deviceId, Long orderId, WorkOrderStatusEnum newStatus, OrderStateChangedEvent event) {
private void handleOrderStatusTransition(Long deviceId, Long orderId, WorkOrderStatusEnum newStatus,
OrderStateChangedEvent event) {
var waitingTasks = orderQueueService.getWaitingTasksByUserId(deviceId);
switch (newStatus) {
case DISPATCHED:
// 工单已推送到工牌,设置工单关联,设备状态转为 BUSY
badgeDeviceStatusService.setCurrentOrder(deviceId, orderId);
badgeDeviceStatusService.updateBadgeStatus(deviceId, BadgeDeviceStatusEnum.BUSY, null, "新工单已推送");
log.info("[BadgeDeviceStatusEventListener] 工单已推送,设备状态转为 BUSY: deviceId={}, orderId={}", deviceId, orderId);
log.info("[BadgeDeviceStatusEventListener] 工单已推送,设备状态转为 BUSY: deviceId={}, orderId={}", deviceId,
orderId);
break;
case CONFIRMED:
@@ -128,7 +158,8 @@ public class BadgeDeviceStatusEventListener {
if (urgentOrderId != null) {
// P0 打断场景:不修改设备状态,保持当前状态
// 紧接着会有 P0 工单的 DISPATCHED 事件,将当前工单更新为 P0 工单
log.info("[BadgeDeviceStatusEventListener] P0打断场景工单已暂停等待P0工单派发: pausedOrderId={}, urgentOrderId={}, deviceId={}",
log.info(
"[BadgeDeviceStatusEventListener] P0打断场景工单已暂停等待P0工单派发: pausedOrderId={}, urgentOrderId={}, deviceId={}",
orderId, urgentOrderId, deviceId);
} else {
// 普通暂停场景:设备状态转为 PAUSED
@@ -174,7 +205,8 @@ public class BadgeDeviceStatusEventListener {
}
} else {
// 取消的不是当前工单(可能是队列中的等待任务),不需要修改设备状态
log.debug("[BadgeDeviceStatusEventListener] 取消的工单非当前执行工单,跳过设备状态更新: cancelledOrderId={}, currentOrderId={}, deviceId={}",
log.debug(
"[BadgeDeviceStatusEventListener] 取消的工单非当前执行工单,跳过设备状态更新: cancelledOrderId={}, currentOrderId={}, deviceId={}",
orderId, currentOrderId, deviceId);
}
break;
@@ -207,8 +239,7 @@ public class BadgeDeviceStatusEventListener {
orderId,
event.getNewStatus().getStatus(),
areaId,
beaconMac
);
beaconMac);
log.debug("[BadgeDeviceStatusEventListener] 设备工单信息已更新: deviceId={}, orderId={}, areaId={}, beaconMac={}",
deviceId, orderId, areaId, beaconMac);
@@ -242,4 +273,3 @@ public class BadgeDeviceStatusEventListener {
}
}
}

View File

@@ -539,7 +539,7 @@ public class CleanOrderEventListener {
// 1. 语音播报 - 通知保洁员工单已完成
if (deviceId != null) {
playVoice(deviceId, CleanNotificationConstants.VoiceTemplate.ORDER_COMPLETED);
playVoice(deviceId, CleanNotificationConstants.VoiceTemplate.ORDER_COMPLETED, orderId);
}
// 2. 发送站内信(给管理员)
@@ -569,8 +569,15 @@ public class CleanOrderEventListener {
* 语音播报
*/
private void playVoice(Long deviceId, String message) {
playVoice(deviceId, message, null);
}
/**
* 语音播报带工单ID
*/
private void playVoice(Long deviceId, String message, Long orderId) {
try {
voiceBroadcastService.broadcast(deviceId, message);
voiceBroadcastService.broadcast(deviceId, message, orderId);
log.debug("[语音播报] 调用成功: deviceId={}, message={}", deviceId, message);
} catch (Exception e) {

View File

@@ -0,0 +1,144 @@
package com.viewsh.module.ops.environment.job;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
import com.viewsh.framework.tenant.core.job.TenantJob;
import com.viewsh.module.ops.api.badge.BadgeDeviceStatusDTO;
import com.viewsh.module.ops.core.dispatch.DispatchEngine;
import com.viewsh.module.ops.core.dispatch.model.DispatchResult;
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.environment.service.badge.BadgeDeviceStatusService;
import com.viewsh.framework.mybatis.core.query.LambdaQueryWrapperX;
import com.xxl.job.core.handler.annotation.XxlJob;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* 空闲设备待办工单检查 Job
* <p>
* 职责:
* 1. 每15分钟扫描所有IDLE状态的设备
* 2. 检查设备是否有QUEUED状态的待办工单
* 3. 如有,自动推送第一条工单
* <p>
* 场景:
* - 工单完成后自动调度失败的补偿
* - 设备上线后有待办但未自动推送的补偿
* - 消息丢失导致未推送的兜底检查
* <p>
* XXL-Job 配置:
* - JobHandler: idleDevicePendingOrderCheckJob
* - Cron: 0 0/15 * * * ? (每 15 分钟)
*
* @author AI
*/
@Slf4j
@Component
public class IdleDevicePendingOrderCheckJob {
@Resource
private BadgeDeviceStatusService badgeDeviceStatusService;
@Resource
private OpsOrderMapper opsOrderMapper;
@Resource
private DispatchEngine dispatchEngine;
/**
* 执行空闲设备待办检查
* <p>
* XXL-Job 调度入口
*
* @return 执行结果
*/
@XxlJob("idleDevicePendingOrderCheckJob")
@TenantJob
public String execute() {
try {
CheckResult result = checkAndDispatchPendingOrders();
return StrUtil.format("空闲设备待办检查完成: 检查 {} 台空闲设备,发现 {} 台有待办,成功推送 {} 个工单,耗时 {} ms",
result.idleDeviceCount, result.deviceWithPendingCount, result.dispatchedCount, result.durationMs);
} catch (Exception e) {
log.error("[IdleDevicePendingOrderCheckJob] 执行失败", e);
return StrUtil.format("空闲设备待办检查失败: {}", e.getMessage());
}
}
/**
* 检查并推送待办工单
*
* @return 检查结果
*/
public CheckResult checkAndDispatchPendingOrders() {
log.info("[IdleDevicePendingOrderCheckJob] 开始检查空闲设备待办工单");
long startTime = System.currentTimeMillis();
int idleDeviceCount = 0;
int deviceWithPendingCount = 0;
int dispatchedCount = 0;
// 1. 获取所有空闲状态的设备
List<BadgeDeviceStatusDTO> idleDevices = badgeDeviceStatusService.listActiveBadges().stream()
.filter(dto -> dto.getStatus() == BadgeDeviceStatusEnum.IDLE)
.toList();
if (CollUtil.isEmpty(idleDevices)) {
log.info("[IdleDevicePendingOrderCheckJob] 无空闲设备,跳过检查");
return new CheckResult(0, 0, 0, System.currentTimeMillis() - startTime);
}
idleDeviceCount = idleDevices.size();
log.info("[IdleDevicePendingOrderCheckJob] 发现 {} 台空闲设备,开始检查待办工单", idleDeviceCount);
// 2. 逐个检查是否有待办工单
for (BadgeDeviceStatusDTO deviceStatus : idleDevices) {
Long deviceId = deviceStatus.getDeviceId();
try {
// 查询该设备的待办工单QUEUED状态按优先级和时间排序
OpsOrderDO pendingOrder = opsOrderMapper.selectOne(new LambdaQueryWrapperX<OpsOrderDO>()
.eq(OpsOrderDO::getAssigneeDeviceId, deviceId)
.eq(OpsOrderDO::getStatus, WorkOrderStatusEnum.QUEUED.getStatus())
.orderByAsc(OpsOrderDO::getPriority) // 优先级高的排前面
.orderByAsc(OpsOrderDO::getCreateTime) // 时间早的排前面
.last("LIMIT 1"));
if (pendingOrder != null) {
deviceWithPendingCount++;
log.info("[IdleDevicePendingOrderCheckJob] 发现空闲设备有待办工单: deviceId={}, orderId={}, orderCode={}",
deviceId, pendingOrder.getId(), pendingOrder.getOrderCode());
// 3. 自动推送工单
DispatchResult result = dispatchEngine.autoDispatchNext(null, deviceId);
if (result.isSuccess()) {
dispatchedCount++;
log.info("[IdleDevicePendingOrderCheckJob] 自动推送成功: deviceId={}, orderId={}",
deviceId, pendingOrder.getId());
} else {
log.warn("[IdleDevicePendingOrderCheckJob] 自动推送失败: deviceId={}, reason={}",
deviceId, result.getMessage());
}
}
} catch (Exception e) {
log.error("[IdleDevicePendingOrderCheckJob] 检查设备待办失败: deviceId={}", deviceId, e);
}
}
long duration = System.currentTimeMillis() - startTime;
log.info("[IdleDevicePendingOrderCheckJob] 检查完成: 空闲设备 {} 台,有待办 {} 台,成功推送 {} 个,耗时 {} ms",
idleDeviceCount, deviceWithPendingCount, dispatchedCount, duration);
return new CheckResult(idleDeviceCount, deviceWithPendingCount, dispatchedCount, duration);
}
/**
* 检查结果
*/
public record CheckResult(int idleDeviceCount, int deviceWithPendingCount, int dispatchedCount, long durationMs) {
}
}

View File

@@ -4,6 +4,8 @@ import cn.hutool.core.map.MapUtil;
import com.viewsh.module.iot.api.device.IotDeviceControlApi;
import com.viewsh.module.iot.api.device.dto.IotDeviceServiceInvokeReqDTO;
import com.viewsh.module.ops.infrastructure.log.enumeration.EventDomain;
import com.viewsh.module.ops.infrastructure.log.enumeration.EventLevel;
import com.viewsh.module.ops.infrastructure.log.recorder.EventLogRecord;
import com.viewsh.module.ops.infrastructure.log.recorder.EventLogRecorder;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
@@ -75,7 +77,14 @@ public class VoiceBroadcastService {
* @param text 播报文本
*/
public void broadcast(Long deviceId, String text) {
broadcast(deviceId, text, TTS_FLAG_URGENT);
broadcast(deviceId, text, TTS_FLAG_URGENT, null);
}
/**
* 播报语音带工单ID
*/
public void broadcast(Long deviceId, String text, Long orderId) {
broadcast(deviceId, text, TTS_FLAG_URGENT, orderId);
}
/**
@@ -86,6 +95,18 @@ public class VoiceBroadcastService {
* @param ttsFlag 播报标志0x01=静默, 0x08=普通, 0x09=紧急)
*/
public void broadcast(Long deviceId, String text, int ttsFlag) {
broadcast(deviceId, text, ttsFlag, null);
}
/**
* 播报语音指定播报类型和工单ID
*
* @param deviceId 设备ID
* @param text 播报文本
* @param ttsFlag 播报标志
* @param orderId 工单ID可选
*/
public void broadcast(Long deviceId, String text, int ttsFlag, Long orderId) {
if (deviceId == null || text == null) {
return;
}
@@ -104,15 +125,38 @@ public class VoiceBroadcastService {
deviceId, text, Integer.toHexString(ttsFlag));
// 记录日志
eventLogRecorder.info("clean", EventDomain.DEVICE, "TTS_SENT",
"语音播报: " + text, deviceId);
if (orderId != null) {
eventLogRecorder.info("clean", EventDomain.DEVICE, "TTS_SENT",
"语音播报: " + text, orderId, deviceId, null);
} else {
eventLogRecorder.info("clean", EventDomain.DEVICE, "TTS_SENT",
"语音播报: " + text, deviceId);
}
} catch (Exception e) {
log.error("[VoiceBroadcast] 播报失败: deviceId={}, text={}", deviceId, text, e);
// 记录错误日志
eventLogRecorder.error("clean", EventDomain.DEVICE, "TTS_FAILED",
"语音播报失败: " + e.getMessage(), deviceId, e);
if (orderId != null) {
eventLogRecorder.error("clean", EventDomain.DEVICE, "TTS_FAILED",
"语音播报失败: " + e.getMessage(), deviceId, e); // error方法目前没有支持orderId的重载或者需要检查一下
// 检查 EventLogRecorder 接口error 方法可能有带 orderId 的重载吗?
// 刚才看 EventLogRecorderImpl 好像没有特别全的重载,或者我需要用 record() 方法手动构建
// 让我先用简单方式error 日志暂时不强求 orderId或者手动构建
eventLogRecorder.record(EventLogRecord.builder()
.module("clean")
.domain(EventDomain.DEVICE)
.eventType("TTS_FAILED")
.message("语音播报失败: " + e.getMessage())
.targetId(orderId)
.targetType("order")
.deviceId(deviceId)
.level(EventLevel.ERROR)
.build());
} else {
eventLogRecorder.error("clean", EventDomain.DEVICE, "TTS_FAILED",
"语音播报失败: " + e.getMessage(), deviceId, e);
}
}
}
@@ -137,7 +181,11 @@ public class VoiceBroadcastService {
* @param text 播报文本
*/
public void broadcastUrgent(Long deviceId, String text) {
broadcast(deviceId, text, TTS_FLAG_URGENT);
broadcast(deviceId, text, TTS_FLAG_URGENT, null);
}
public void broadcastUrgent(Long deviceId, String text, Long orderId) {
broadcast(deviceId, text, TTS_FLAG_URGENT, orderId);
}
/**

View File

@@ -112,6 +112,11 @@ public class EventLogRecord {
*/
private String targetType;
/**
* 工单ID便于直接关联查询
*/
private Long orderId;
// ==================== 扩展字段 ====================
/**
@@ -181,7 +186,7 @@ public class EventLogRecord {
* 创建设备事件日志
*/
public static EventLogRecord forDevice(String module, EventDomain domain, String eventType,
String message, Long deviceId) {
String message, Long deviceId) {
return EventLogRecord.builder()
.module(module)
.domain(domain)
@@ -195,7 +200,7 @@ public class EventLogRecord {
* 创建工单事件日志
*/
public static EventLogRecord forOrder(String module, EventDomain domain, String eventType,
String message, Long orderId, Long deviceId, Long personId) {
String message, Long orderId, Long deviceId, Long personId) {
return EventLogRecord.builder()
.module(module)
.domain(domain)
@@ -212,7 +217,7 @@ public class EventLogRecord {
* 创建系统事件日志
*/
public static EventLogRecord forSystem(String module, EventDomain domain, String eventType,
String message) {
String message) {
return EventLogRecord.builder()
.module(module)
.domain(domain)

View File

@@ -8,6 +8,8 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import java.util.Map;
/**
* 事件日志记录器实现
* <p>
@@ -31,7 +33,6 @@ public class EventLogRecorderImpl implements EventLogRecorder {
@Resource
private EventLogPersister persister;
/**
* 异步记录日志(默认方式)
* <p>
@@ -110,7 +111,7 @@ public class EventLogRecorderImpl implements EventLogRecorder {
@Override
public void info(String module, EventDomain domain, String eventType, String message,
Long orderId, Long deviceId, Long personId) {
Long orderId, Long deviceId, Long personId) {
record(EventLogRecord.builder()
.module(module)
.domain(domain)
@@ -165,7 +166,7 @@ public class EventLogRecorderImpl implements EventLogRecorder {
@Override
public void error(String module, EventDomain domain, String eventType, String message,
Long deviceId, Throwable throwable) {
Long deviceId, Throwable throwable) {
EventLogRecord record = EventLogRecord.builder()
.module(module)
.domain(domain)
@@ -185,6 +186,12 @@ public class EventLogRecorderImpl implements EventLogRecorder {
* 转换为 DO
*/
private OpsBusinessEventLogDO convertToDO(EventLogRecord record) {
// payload 为空或空 Map 时设为 null避免存储 {}
Map<String, Object> payload = record.getPayload();
if (payload != null && payload.isEmpty()) {
payload = null;
}
return OpsBusinessEventLogDO.builder()
.eventTime(record.getEventTime())
.eventLevel(record.getLevel() != null ? record.getLevel().getCode() : EventLevel.INFO.getCode())
@@ -202,7 +209,7 @@ public class EventLogRecorderImpl implements EventLogRecorder {
.targetType(record.getTargetType())
.eventMessage(record.getMessage())
.eventSummary(record.getSummary())
.eventPayload(record.getPayload())
.eventPayload(payload)
.build();
}
@@ -228,10 +235,12 @@ public class EventLogRecorderImpl implements EventLogRecorder {
*/
private void logConsole(EventLogRecord record) {
String deviceInfo = record.getDeviceId() != null
? " [设备:" + record.getDeviceId() + (record.getDeviceName() != null ? "(" + record.getDeviceName() + ")" : "") + "]"
? " [设备:" + record.getDeviceId()
+ (record.getDeviceName() != null ? "(" + record.getDeviceName() + ")" : "") + "]"
: "";
String personInfo = record.getPersonId() != null
? " [人员:" + record.getPersonId() + (record.getPersonName() != null ? "(" + record.getPersonName() + ")" : "") + "]"
? " [人员:" + record.getPersonId()
+ (record.getPersonName() != null ? "(" + record.getPersonName() + ")" : "") + "]"
: "";
String targetInfo = record.getTargetId() != null
? " [" + record.getTargetType() + ":" + record.getTargetId() + "]"