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,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);
}
}
// ==================== 直接播报模式(特殊场景) ====================
/**