feat(ops,iot): 工单语音播报循环机制 + 统一按键逻辑
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

核心改动:
- 新增循环播报机制:DISPATCHED 状态持续播报"工单来啦"直到按键确认
- 统一按键逻辑:confirmKeyId 和 queryKeyId 都路由到同一处理逻辑,
  根据工单状态智能判断行为(确认/查询/无工单提示)
- ARRIVED/COMPLETED 状态静默不播报,CANCELLED 保留取消播报
- 修复 P0:确认去重后按键不再静默,改为发查询事件给反馈
- 修复 P0:PAUSED 状态(P0打断)时停止被打断工单的循环播报
- 修复 P1:handleCompleted 补全 deviceId 兜底逻辑
- 修复 P1:stopLoop 只移除循环消息,保留非循环消息

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
lzh
2026-02-26 17:13:03 +08:00
parent 6cb784a2d8
commit 5ee039b0bf
6 changed files with 281 additions and 75 deletions

View File

@@ -74,74 +74,62 @@ public class ButtonEventRuleProcessor {
log.debug("[ButtonEvent] 按键解析成功deviceId={}, buttonId={}", deviceId, buttonId);
// 4. 匹配按键类型并处理
if (buttonId.equals(buttonConfig.getConfirmKeyId())) {
// 确认键
handleConfirmButton(deviceId, buttonId);
} else if (buttonId.equals(buttonConfig.getQueryKeyId())) {
// 查询键
handleQueryButton(deviceId, buttonId);
// 4. 匹配按键类型并处理(确认键和查询键统一路由到同一逻辑)
if (buttonId.equals(buttonConfig.getConfirmKeyId())
|| buttonId.equals(buttonConfig.getQueryKeyId())) {
// 所有已知按键统一走绿色按键逻辑(根据工单状态智能判断行为)
handleGreenButton(deviceId, buttonId);
} else {
log.debug("[ButtonEvent] 未配置的按键deviceId={}, buttonId={}", deviceId, buttonId);
}
}
/**
* 处理确认按键
* 处理绿色按键(统一按键逻辑)
* <p>
* 保洁员按下确认键,确认接收工单
* 根据当前工单状态智能判断行为:
* - 无工单发布查询事件Ops 端播报"没有工单"
* - DISPATCHED发布确认事件触发确认状态转换 + 停止循环 + 播报地点)
* - CONFIRMED/ARRIVED发布查询事件播报地点
* - 其他状态:发布查询事件(兜底处理)
*/
private void handleConfirmButton(Long deviceId, Integer buttonId) {
log.info("[ButtonEvent] 确认键按下deviceId={}, buttonId={}", deviceId, buttonId);
// 1. 查询设备当前工单
BadgeDeviceStatusRedisDAO.OrderInfo currentOrder = badgeDeviceStatusRedisDAO.getCurrentOrder(deviceId);
if (currentOrder == null) {
log.warn("[ButtonEvent] 设备无当前工单跳过确认deviceId={}", deviceId);
return;
}
Long orderId = currentOrder.getOrderId();
// 2. 防重复检查(短时间内同一工单的确认操作去重)
String dedupKey = String.format("iot:clean:button:dedup:confirm:%s:%s", deviceId, orderId);
Boolean firstTime = stringRedisTemplate.opsForValue()
.setIfAbsent(dedupKey, "1", 10, java.util.concurrent.TimeUnit.SECONDS);
if (!firstTime) {
log.info("[ButtonEvent] 确认操作重复跳过deviceId={}, orderId={}", deviceId, orderId);
return;
}
// 3. 发布工单确认事件
publishConfirmEvent(deviceId, orderId, buttonId);
log.info("[ButtonEvent] 发布工单确认事件deviceId={}, orderId={}", deviceId, orderId);
}
/**
* 处理查询按键
* <p>
* 保洁员按下查询键,查询当前工单信息
*/
private void handleQueryButton(Long deviceId, Integer buttonId) {
log.info("[ButtonEvent] 查询键按下deviceId={}, buttonId={}", deviceId, buttonId);
private void handleGreenButton(Long deviceId, Integer buttonId) {
log.info("[ButtonEvent] 绿色按键按下deviceId={}, buttonId={}", deviceId, buttonId);
// 1. 查询设备当前工单
BadgeDeviceStatusRedisDAO.OrderInfo currentOrder = badgeDeviceStatusRedisDAO.getCurrentOrder(deviceId);
if (currentOrder == null) {
// 无工单 → 发布查询事件Ops 端播报"没有工单"
log.info("[ButtonEvent] 设备无当前工单deviceId={}", deviceId);
// 发布查询结果事件(无工单)
publishQueryEvent(deviceId, null, buttonId, "当前无工单");
return;
}
// 2. 发布查询事件
publishQueryEvent(deviceId, currentOrder.getOrderId(), buttonId, "查询当前工单");
Long orderId = currentOrder.getOrderId();
String orderStatus = currentOrder.getStatus();
log.info("[ButtonEvent] 发布工单查询事件deviceId={}, orderId={}", deviceId, currentOrder.getOrderId());
// 2. 根据工单状态智能分派
if ("DISPATCHED".equals(orderStatus)) {
// DISPATCHED → 发布确认事件(触发确认 + 停止循环 + 播报地点)
// 防重复检查
String dedupKey = String.format("iot:clean:button:dedup:confirm:%s:%s", deviceId, orderId);
Boolean firstTime = stringRedisTemplate.opsForValue()
.setIfAbsent(dedupKey, "1", 10, java.util.concurrent.TimeUnit.SECONDS);
if (!Boolean.TRUE.equals(firstTime)) {
// 重复确认不再静默,改为发查询事件给保洁员反馈(播报地点)
log.info("[ButtonEvent] 确认操作重复转为查询deviceId={}, orderId={}", deviceId, orderId);
publishQueryEvent(deviceId, orderId, buttonId, "重复确认,查询当前工单");
return;
}
publishConfirmEvent(deviceId, orderId, buttonId);
log.info("[ButtonEvent] DISPATCHED状态发布确认事件deviceId={}, orderId={}", deviceId, orderId);
} else {
// CONFIRMED / ARRIVED / 其他状态 → 发布查询事件(播报地点)
publishQueryEvent(deviceId, orderId, buttonId, "查询当前工单");
log.info("[ButtonEvent] {}状态发布查询事件deviceId={}, orderId={}", orderStatus, deviceId, orderId);
}
}
/**

View File

@@ -74,6 +74,12 @@ public class CleanNotificationConstants {
// ==================== 新工单播报 ====================
/**
* 新工单循环播报(铃声式,类似来电提醒)
* 用于 DISPATCHED 状态的持续播报,直到保洁员按键确认
*/
public static final String NEW_ORDER_RING = "工单来啦";
/**
* 新工单播报(简短版)
*/
@@ -89,7 +95,7 @@ public class CleanNotificationConstants {
* 新工单播报(完整版)
* 参数: {areaName} - 区域名称, {orderTitle} - 工单标题(截断)
*/
public static final String NEW_ORDER_FULL = "新工单来啦,请按1键进行确认";
public static final String NEW_ORDER_FULL = "新工单来啦,请按键进行确认";
// ==================== 工单确认播报 ====================
@@ -188,7 +194,7 @@ public class CleanNotificationConstants {
/**
* 按键查询播报(无工单时)
*/
public static final String QUERY_NO_ORDER = "暂无待办工单";
public static final String QUERY_NO_ORDER = "没有工单";
/**
* 按键查询播报(待办数量提示)

View File

@@ -273,10 +273,16 @@ public class CleanOrderEventListener {
recordOrderConfirmedLog(orderId, deviceId, event);
if (deviceId != null) {
// 发送确认成功语音播报(使用统一模板)
// 1. 停止循环播报
voiceBroadcastService.stopLoop(deviceId);
// 2. 播报地点信息
OpsOrderDO order = opsOrderMapper.selectById(orderId);
String confirmMessage = CleanNotificationConstants.VoiceBuilder.buildOrderConfirmed(order);
playVoice(deviceId, confirmMessage, orderId);
String areaName = CleanNotificationConstants.VoiceBuilder.getAreaName(
order != null ? order.getLocation() : null);
String locationMessage = String.format(
CleanNotificationConstants.VoiceTemplate.QUERY_HAS_ORDER, areaName);
playVoice(deviceId, locationMessage, orderId);
}
log.info("[CleanOrderEventListener] 工单已确认: orderId={}, deviceId={}", orderId, deviceId);
@@ -304,11 +310,9 @@ public class CleanOrderEventListener {
// 2. 计算并更新响应时长(下发→到岗,排除暂停时间)
updateResponseSeconds(orderId);
// 3. 语音播报提醒开始作业
// 3. 停止循环播报(处理信标自动到岗 DISPATCHED→ARRIVED 场景)+ 静默(不播报)
if (deviceId != null) {
OpsOrderDO order = opsOrderMapper.selectById(orderId);
String arrivedMessage = CleanNotificationConstants.VoiceBuilder.buildOrderArrived(order);
playVoice(deviceId, arrivedMessage, orderId);
voiceBroadcastService.stopLoop(deviceId);
}
// 4. 记录到岗业务日志
@@ -328,11 +332,26 @@ public class CleanOrderEventListener {
public void handlePaused(OrderStateChangedEvent event) {
Long orderId = event.getOrderId();
// 记录暂停开始时间到扩展表
// 1. 停止循环播报P0打断时被打断的工单可能正在循环播报"工单来啦"
Long deviceId = event.getPayloadLong("assigneeId");
if (deviceId == null) {
deviceId = event.getOperatorId();
}
if (deviceId == null) {
OpsOrderDO order = opsOrderMapper.selectById(orderId);
if (order != null) {
deviceId = order.getAssigneeId();
}
}
if (deviceId != null) {
voiceBroadcastService.stopLoop(deviceId);
}
// 2. 记录暂停开始时间到扩展表
recordPauseStartTime(orderId);
// 设备状态由 BadgeDeviceStatusEventListener 统一处理
log.info("[CleanOrderEventListener] 暂停时间已记录: orderId={}", orderId);
log.info("[CleanOrderEventListener] 暂停处理完成: orderId={}, deviceId={}", orderId, deviceId);
}
/**
@@ -342,10 +361,21 @@ public class CleanOrderEventListener {
public void handleCompleted(OrderStateChangedEvent event) {
Long orderId = event.getOrderId();
// 获取 deviceId优先从 payload 获取,其次从工单获取
// 获取 deviceId优先从 payload 获取,其次从工单 assigneeId 兜底
Long deviceId = event.getPayloadLong("deviceId");
if (deviceId == null) {
OpsOrderDO order = opsOrderMapper.selectById(orderId);
if (order != null) {
deviceId = order.getAssigneeId();
}
}
// 记录完成时间并计算完成时长
// 兜底:停止循环播报(安全调用)
if (deviceId != null) {
voiceBroadcastService.stopLoop(deviceId);
}
// 记录完成时间并计算完成时长(静默,不播报)
LocalDateTime completedTime = LocalDateTime.now();
OpsOrderCleanExtDO ext = cleanExtMapper.selectByOpsOrderId(orderId);
@@ -404,8 +434,9 @@ public class CleanOrderEventListener {
updateExt.setCompletedTime(LocalDateTime.now());
cleanExtMapper.insertOnDuplicateKeyUpdate(updateExt);
// 2. 语音播报通知保洁员工单已取消
// 2. 停止循环播报 + 语音播报通知保洁员工单已取消(取消是异常情况,需要通知)
if (deviceId != null) {
voiceBroadcastService.stopLoop(deviceId);
playVoice(deviceId, CleanNotificationConstants.VoiceTemplate.ORDER_CANCELLED, orderId);
}
@@ -446,25 +477,23 @@ public class CleanOrderEventListener {
}
/**
* 异步执行工单完成后的通知和派单
* 异步执行工单完成后的派单
* <p>
* 先发送完成通知,再立即触发下一个任务派发。
* 语音播报顺序和间隔由 TTS 队列({@link com.viewsh.module.ops.environment.service.voice.TtsQueueConsumer}
* 按设备维度控制,同一设备前后播报间隔由 ops.tts.queue.interval-ms 配置。
* 完成状态静默(不播报),直接触发下一个任务派发。
* 如果下一个任务派发成功,会触发 handleDispatched 启动新的循环播报。
*/
@Async("ops-task-executor")
public void asyncCompleteAndDispatchNext(Long orderId, Long deviceId) {
// 1. 发送完成通知入TTS队列
sendOrderCompletedNotification(orderId, deviceId);
// 静默:不发送完成通知
// 2. 自动推送下一个任务(新任务通知也入TTS队列由队列控制播报间隔
// 自动推送下一个任务(新任务通知会触发 handleDispatched 启动循环播报
cleanOrderService.autoDispatchNextOrder(orderId, deviceId);
}
// ==================== 通知方法 ====================
/**
* 发送新工单通知(语音播报 + 站内信)
* 发送新工单通知(循环语音播报 + 站内信)
*/
@Async("ops-task-executor")
public void sendNewOrderNotification(Long deviceId, Long orderId) {
@@ -477,9 +506,9 @@ public class CleanOrderEventListener {
log.info("[新工单通知] deviceId={}, orderId={}", deviceId, orderId);
// 1. 语音播报(使用统一模板构建器)
String voiceMessage = CleanNotificationConstants.VoiceBuilder.buildNewOrder(order, true);
playVoice(deviceId, voiceMessage, orderId);
// 1. 启动循环语音播报"工单来了"
voiceBroadcastService.broadcastLoop(deviceId,
CleanNotificationConstants.VoiceTemplate.NEW_ORDER_RING, orderId);
// 2. 发送站内信(暂时发送到管理员)
sendNotifyMessage(1L,

View File

@@ -46,6 +46,8 @@ public class TtsQueueConsumer {
private static final String LOCK_KEY_PREFIX = "ops:tts:lock:";
private static final String LOOP_KEY_PREFIX = "ops:tts:loop:";
@Value("${ops.tts.queue.enabled:true}")
private boolean queueEnabled;
@@ -200,6 +202,21 @@ public class TtsQueueConsumer {
deviceBroadcastLock.put(deviceId, System.currentTimeMillis());
log.info("[TTS队列] 播报成功: deviceId={}, text={}",
deviceId, message.getText());
// 循环消息:检查 Redis 循环标记,若存在则重新入队
if (message.isLoopable() && isLoopActive(deviceId)) {
TtsQueueMessage loopMsg = TtsQueueMessage.loopMessage(
deviceId, message.getText(), message.getOrderId());
try {
String loopJson = objectMapper.writeValueAsString(loopMsg);
redisTemplate.opsForList().rightPush(queueKey, loopJson);
redisTemplate.expire(queueKey, 1, TimeUnit.HOURS);
log.debug("[TTS队列] 循环消息重新入队: deviceId={}", deviceId);
} catch (Exception ex) {
log.warn("[TTS队列] 循环消息重新入队失败: deviceId={}", deviceId, ex);
}
}
return true;
} else {
// 播报失败,释放锁(允许立即重试)
@@ -335,6 +352,99 @@ public class TtsQueueConsumer {
}
}
// ==================== 循环播报控制 ====================
/**
* 启动循环播报标记
*
* @param deviceId 设备ID
* @param orderId 工单ID
*/
public void startLoop(Long deviceId, Long orderId) {
String loopKey = LOOP_KEY_PREFIX + deviceId;
redisTemplate.opsForValue().set(loopKey, String.valueOf(orderId), 1, TimeUnit.HOURS);
log.info("[TTS队列] 启动循环播报: deviceId={}, orderId={}", deviceId, orderId);
}
/**
* 停止循环播报
* <p>
* 删除循环标记,并从队列中移除循环消息(保留非循环消息)
*
* @param deviceId 设备ID
*/
public void stopLoop(Long deviceId) {
String loopKey = LOOP_KEY_PREFIX + deviceId;
Boolean deleted = redisTemplate.delete(loopKey);
// 从队列中移除循环消息,保留非循环消息(如取消播报、待办播报等)
int removed = removeLoopMessages(deviceId);
log.info("[TTS队列] 停止循环播报: deviceId={}, loopKeyDeleted={}, removedMessages={}", deviceId, deleted, removed);
}
/**
* 从队列中移除所有循环消息,保留非循环消息
*
* @param deviceId 设备ID
* @return 移除的消息数量
*/
private int removeLoopMessages(Long deviceId) {
String queueKey = getQueueKey(deviceId);
int removedCount = 0;
try {
Long size = redisTemplate.opsForList().size(queueKey);
if (size == null || size == 0) {
return 0;
}
// 取出所有消息,过滤掉循环消息后重新入队
java.util.List<Object> allMessages = new java.util.ArrayList<>();
for (int i = 0; i < size; i++) {
Object msg = redisTemplate.opsForList().leftPop(queueKey);
if (msg == null) {
break;
}
allMessages.add(msg);
}
// 过滤:保留非循环消息
for (Object msgObj : allMessages) {
TtsQueueMessage message = parseMessage(msgObj);
if (message != null && message.isLoopable()) {
removedCount++;
} else {
// 非循环消息放回队列
redisTemplate.opsForList().rightPush(queueKey, msgObj);
}
}
// 如果队列非空,刷新 TTL
if (removedCount < allMessages.size()) {
redisTemplate.expire(queueKey, 1, TimeUnit.HOURS);
}
} catch (Exception e) {
log.warn("[TTS队列] 移除循环消息失败,降级为清空队列: deviceId={}", deviceId, e);
// 降级:清空整个队列,确保循环一定停止
clearQueue(deviceId);
}
return removedCount;
}
/**
* 检查循环播报是否激活
*
* @param deviceId 设备ID
* @return 是否激活
*/
public boolean isLoopActive(Long deviceId) {
String loopKey = LOOP_KEY_PREFIX + deviceId;
return Boolean.TRUE.equals(redisTemplate.hasKey(loopKey));
}
// ==================== 队列查询 ====================
/**
* 获取队列长度
*/

View File

@@ -88,6 +88,11 @@ public class TtsQueueMessage implements Serializable {
*/
private Integer maxRetry;
/**
* 是否为循环消息(消费后重新入队,实现持续播报)
*/
private Boolean loopable;
/**
* 创建按序消息FIFOrightPush 追加到尾部)
* <p>
@@ -126,6 +131,33 @@ public class TtsQueueMessage implements Serializable {
.build();
}
/**
* 创建循环消息(消费后自动重新入队,实现持续播报)
* <p>
* ttsFlag=0x09紧急通知带显示priority=5普通优先级
* 消费后如果 Redis 中存在循环标记,则重新入队继续播报
*/
public static TtsQueueMessage loopMessage(Long deviceId, String text, Long orderId) {
return TtsQueueMessage.builder()
.deviceId(deviceId)
.text(text)
.ttsFlag(TTS_FLAG_URGENT)
.orderId(orderId)
.priority(PRIORITY_NORMAL)
.createTime(System.currentTimeMillis())
.retryCount(0)
.maxRetry(2)
.loopable(true)
.build();
}
/**
* 检查是否为循环消息
*/
public boolean isLoopable() {
return Boolean.TRUE.equals(loopable);
}
/**
* 增加重试次数
*/

View File

@@ -101,6 +101,47 @@ public class VoiceBroadcastService {
}
}
// ==================== 循环播报模式 ====================
/**
* 启动循环播报(消费后自动重新入队,持续播报直到 stopLoop
* <p>
* 用于新工单推送场景:设备持续播报"工单来了"直到保洁员按键确认。
*
* @param deviceId 设备ID
* @param text 播报文本
* @param orderId 工单ID
*/
public void broadcastLoop(Long deviceId, String text, Long orderId) {
if (deviceId == null || text == null) {
return;
}
if (queueEnabled) {
// 设置循环标记
ttsQueueConsumer.startLoop(deviceId, orderId);
// 入队循环消息
enqueueOrFallback(TtsQueueMessage.loopMessage(deviceId, text, orderId));
} else {
broadcastDirect(deviceId, text, TtsQueueMessage.TTS_FLAG_URGENT, orderId);
}
}
/**
* 停止循环播报
* <p>
* 删除循环标记并清空该设备的播报队列。安全调用,无循环标记时也不会报错。
*
* @param deviceId 设备ID
*/
public void stopLoop(Long deviceId) {
if (deviceId == null) {
return;
}
if (queueEnabled) {
ttsQueueConsumer.stopLoop(deviceId);
}
}
// ==================== 直接播报模式(特殊场景) ====================
/**