Merge branch 'master' into feat/multi-tenant
吸收 master 今日 9 个工单链路修复: - autoDispatchNext/dispatch 空闲兜底 + FOR UPDATE 并发防护 - 状态转换审计闭环(AFTER_COMMIT/AFTER_ROLLBACK) - 队列楼层权重强优先 + 三级 baseline 兜底 + N+1 优化 - 工牌 nickname 回填 - CleanOrderAutoCancelJob 超时工单自动取消
This commit is contained in:
@@ -9,6 +9,7 @@ import com.viewsh.module.ops.core.dispatch.strategy.ScheduleStrategy;
|
||||
import com.viewsh.module.ops.core.lifecycle.OrderLifecycleManager;
|
||||
import com.viewsh.module.ops.core.lifecycle.model.OrderTransitionRequest;
|
||||
import com.viewsh.module.ops.core.lifecycle.model.OrderTransitionResult;
|
||||
import com.viewsh.module.ops.core.lifecycle.model.TransitionErrorCode;
|
||||
import com.viewsh.module.ops.dal.dataobject.workorder.OpsOrderDO;
|
||||
import com.viewsh.module.ops.dal.mysql.workorder.OpsOrderMapper;
|
||||
import com.viewsh.module.ops.enums.OperatorTypeEnum;
|
||||
@@ -178,6 +179,22 @@ public class DispatchEngineImpl implements DispatchEngine {
|
||||
public DispatchResult autoDispatchNext(Long completedOrderId, Long assigneeId) {
|
||||
log.info("任务完成后自动派发下一单: completedOrderId={}, assigneeId={}", completedOrderId, assigneeId);
|
||||
|
||||
if (assigneeId == null) {
|
||||
log.warn("autoDispatchNext 缺少执行人,跳过派发: completedOrderId={}", completedOrderId);
|
||||
return DispatchResult.success("缺少执行人,跳过派发", null);
|
||||
}
|
||||
|
||||
// 空闲校验:若执行人仍挂着其他活跃工单(DISPATCHED/CONFIRMED/ARRIVED/PAUSED),
|
||||
// 说明设备尚未真正空闲,不应再派发新任务——否则会触发"同一设备并行多单"的状态错乱,
|
||||
// 典型场景是管理员手动取消一个僵尸 DISPATCHED 单时,handleCancelled 会调到这里。
|
||||
List<OpsOrderDO> activeOrders = orderMapper.selectActiveByAssignee(assigneeId, completedOrderId);
|
||||
if (!activeOrders.isEmpty()) {
|
||||
OpsOrderDO head = activeOrders.get(0);
|
||||
log.info("执行人仍有活跃工单,跳过自动派发: assigneeId={}, completedOrderId={}, activeCount={}, sampleOrderId={}, sampleStatus={}",
|
||||
assigneeId, completedOrderId, activeOrders.size(), head.getId(), head.getStatus());
|
||||
return DispatchResult.success("执行人非空闲,跳过派发", assigneeId);
|
||||
}
|
||||
|
||||
Long fallbackAreaId = null;
|
||||
OpsOrderDO completedOrder = orderMapper.selectById(completedOrderId);
|
||||
if (completedOrder != null) {
|
||||
@@ -229,7 +246,9 @@ public class DispatchEngineImpl implements DispatchEngine {
|
||||
.reason("等待队列动态重排后自动派发")
|
||||
.build();
|
||||
|
||||
OrderTransitionResult result = orderLifecycleManager.transition(request);
|
||||
// 走 dispatch() 而不是 transition():dispatch 内部会先做 FOR UPDATE 不变量检查
|
||||
// (Bug #2 防线),避免 autoDispatchNext 在"从队列派发"这一类入口绕过串行化。
|
||||
OrderTransitionResult result = orderLifecycleManager.dispatch(request);
|
||||
|
||||
if (result.isSuccess()) {
|
||||
return DispatchResult.success("已按队列总分派发下一单", assigneeId);
|
||||
@@ -346,6 +365,23 @@ public class DispatchEngineImpl implements DispatchEngine {
|
||||
Long orderId = context.getOrderId();
|
||||
Long assigneeId = context.getRecommendedAssigneeId();
|
||||
|
||||
// 兜底校验:调度策略基于 Redis 的设备状态判空闲,可能与 MySQL 的 ops_order 实际活跃态不一致
|
||||
// (例如设备 Redis 状态被某次 COMPLETED 清回 IDLE 但历史 CONFIRMED/DISPATCHED 单仍残留)。
|
||||
// 若分配路径会真正推送工单给设备(DIRECT_DISPATCH / PUSH_AND_ENQUEUE),
|
||||
// 此处再查一次 MySQL,非空闲时强制降级到 ENQUEUE_ONLY,避免同一设备并行多单的状态错乱。
|
||||
if (assigneeId != null
|
||||
&& (decision.getPath() == DispatchPath.DIRECT_DISPATCH
|
||||
|| decision.getPath() == DispatchPath.PUSH_AND_ENQUEUE)) {
|
||||
List<OpsOrderDO> activeOrders = orderMapper.selectActiveByAssignee(assigneeId, orderId);
|
||||
if (!activeOrders.isEmpty()) {
|
||||
OpsOrderDO head = activeOrders.get(0);
|
||||
log.warn("调度决策为 {} 但执行人仍挂活跃工单,降级为仅入队: orderId={}, assigneeId={}, activeCount={}, sampleOrderId={}, sampleStatus={}",
|
||||
decision.getPath(), orderId, assigneeId,
|
||||
activeOrders.size(), head.getId(), head.getStatus());
|
||||
return executeEnqueueOnly(context, assigneeId);
|
||||
}
|
||||
}
|
||||
|
||||
switch (decision.getPath()) {
|
||||
case DIRECT_DISPATCH:
|
||||
return executeDirectDispatch(context, assigneeId);
|
||||
@@ -402,9 +438,25 @@ public class DispatchEngineImpl implements DispatchEngine {
|
||||
DispatchPath.DIRECT_DISPATCH,
|
||||
result.getQueueId()
|
||||
);
|
||||
} else {
|
||||
return DispatchResult.fail("直接派单失败: " + result.getMessage());
|
||||
}
|
||||
|
||||
// 并发冲突兜底:dispatch 入口的 FOR UPDATE 判定执行人已有活跃工单,
|
||||
// 此时工单仍在原状态(通常是 PENDING)。如果仍是 PENDING,直接降级为入队,
|
||||
// 避免工单悬空;若已是 QUEUED(例如从队列派发被抢先),则让它继续留在队列等下一轮。
|
||||
if (result.getErrorCode() == TransitionErrorCode.ASSIGNEE_HAS_ACTIVE_ORDER) {
|
||||
OpsOrderDO order = orderMapper.selectById(context.getOrderId());
|
||||
String currentStatus = order != null ? order.getStatus() : null;
|
||||
if (WorkOrderStatusEnum.QUEUED.getStatus().equals(currentStatus)) {
|
||||
log.warn("直接派单被 FOR UPDATE 拒绝且工单已在队列中,保持 QUEUED 等待下一轮: orderId={}, assigneeId={}",
|
||||
context.getOrderId(), assigneeId);
|
||||
return DispatchResult.fail("并发冲突,已留在队列等待: " + result.getMessage());
|
||||
}
|
||||
log.warn("直接派单被 FOR UPDATE 拒绝,降级为入队: orderId={}, assigneeId={}, reason={}",
|
||||
context.getOrderId(), assigneeId, result.getMessage());
|
||||
return executeEnqueueOnly(context, assigneeId);
|
||||
}
|
||||
|
||||
return DispatchResult.fail("直接派单失败: " + result.getMessage());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -427,8 +479,15 @@ public class DispatchEngineImpl implements DispatchEngine {
|
||||
.reason("自动推送等待任务")
|
||||
.build();
|
||||
|
||||
orderLifecycleManager.dispatch(dispatchRequest);
|
||||
log.info("已推送等待任务: taskId={}", firstWaiting.getId());
|
||||
OrderTransitionResult pushResult = orderLifecycleManager.dispatch(dispatchRequest);
|
||||
if (pushResult.isSuccess()) {
|
||||
log.info("已推送等待任务: taskId={}", firstWaiting.getId());
|
||||
} else {
|
||||
// 可能被 dispatch() 里的 FOR UPDATE 拒绝:此处不中断新任务入队流程,
|
||||
// 但要把"推送失败"清晰落在日志里,避免 "已推送" 说谎误导运维排查。
|
||||
log.warn("推送等待任务失败,继续执行新任务入队: taskId={}, orderId={}, error={}",
|
||||
firstWaiting.getId(), firstWaiting.getOpsOrderId(), pushResult.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// 新任务入队
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package com.viewsh.module.ops.core.event;
|
||||
|
||||
import com.viewsh.module.ops.core.lifecycle.model.TransitionErrorCode;
|
||||
import com.viewsh.module.ops.enums.OperatorTypeEnum;
|
||||
import com.viewsh.module.ops.enums.WorkOrderStatusEnum;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 工单状态转换"尝试"领域事件
|
||||
* <p>
|
||||
* 与 {@link OrderStateChangedEvent} 的区别:
|
||||
* <ul>
|
||||
* <li>{@code OrderStateChangedEvent} 仅在状态转换 <b>成功</b> 后发布(EventPublishHandler),
|
||||
* 订阅方是业务层监听器(TTS 播报、设备状态同步等)。</li>
|
||||
* <li>{@code OrderTransitionAttemptedEvent} 在每一次 transition 尝试时都发布——成功、失败、
|
||||
* FOR UPDATE 被拒 都发。订阅方是审计日志,用于打穿事务回滚造成的审计断链
|
||||
* (rollback 场景下 ops_order_event 无记录,bus_log 需独立事务补齐)。</li>
|
||||
* </ul>
|
||||
* 事务边界:
|
||||
* <ul>
|
||||
* <li>发布方在主事务内 {@code publishEvent},事件会被 Spring 挂在当前事务的 synchronization 上。</li>
|
||||
* <li>订阅方用 {@code @TransactionalEventListener(AFTER_COMMIT)} 或 {@code AFTER_ROLLBACK}
|
||||
* 分别处理 commit 与 rollback 场景,保证两种结果都留痕。</li>
|
||||
* </ul>
|
||||
*
|
||||
* @author lzh
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class OrderTransitionAttemptedEvent {
|
||||
|
||||
/** 工单ID */
|
||||
private Long orderId;
|
||||
|
||||
/** 工单类型(CLEAN / SECURITY / REPAIR / SERVICE) */
|
||||
private String orderType;
|
||||
|
||||
/** 工单编号(冗余,便于日志检索) */
|
||||
private String orderCode;
|
||||
|
||||
/** 原状态(查询时的当前状态) */
|
||||
private WorkOrderStatusEnum fromStatus;
|
||||
|
||||
/** 目标状态 */
|
||||
private WorkOrderStatusEnum targetStatus;
|
||||
|
||||
/** 执行人ID */
|
||||
private Long assigneeId;
|
||||
|
||||
/** 操作人类型 */
|
||||
private OperatorTypeEnum operatorType;
|
||||
|
||||
/** 操作人ID */
|
||||
private Long operatorId;
|
||||
|
||||
/** 原因/备注 */
|
||||
private String reason;
|
||||
|
||||
/**
|
||||
* 发布时的"声明结果"。
|
||||
* <p>
|
||||
* 注意:这是发布瞬间的判断;如果后续 handler 抛异常导致整个事务 rollback,
|
||||
* 监听器在 {@code AFTER_ROLLBACK} 阶段应强制将其视为失败。
|
||||
*/
|
||||
private boolean success;
|
||||
|
||||
/** 失败错误码(success=false 时有值) */
|
||||
private TransitionErrorCode errorCode;
|
||||
|
||||
/** 失败原因(简要消息,success=false 时有值) */
|
||||
private String errorMessage;
|
||||
|
||||
/** 异常摘要(success=false 且存在异常时有值,只保留 class + message,不带堆栈) */
|
||||
private String causeSummary;
|
||||
|
||||
/** 事件时间 */
|
||||
private LocalDateTime attemptedAt;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.viewsh.module.ops.core.lifecycle;
|
||||
|
||||
import com.viewsh.module.ops.core.event.OrderTransitionAttemptedEvent;
|
||||
import com.viewsh.module.ops.core.lifecycle.handler.EventPublishHandler;
|
||||
import com.viewsh.module.ops.core.lifecycle.handler.QueueSyncHandler;
|
||||
import com.viewsh.module.ops.core.lifecycle.handler.StateTransitionHandler;
|
||||
@@ -7,6 +8,10 @@ import com.viewsh.module.ops.core.lifecycle.handler.TransitionHandler;
|
||||
import com.viewsh.module.ops.core.lifecycle.model.OrderTransitionRequest;
|
||||
import com.viewsh.module.ops.core.lifecycle.model.OrderTransitionResult;
|
||||
import com.viewsh.module.ops.core.lifecycle.model.TransitionContext;
|
||||
import com.viewsh.module.ops.core.lifecycle.model.TransitionErrorCode;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import com.viewsh.module.ops.dal.dataobject.workorder.OpsOrderDO;
|
||||
import com.viewsh.module.ops.dal.mysql.workorder.OpsOrderMapper;
|
||||
import com.viewsh.module.ops.enums.OperatorTypeEnum;
|
||||
@@ -62,6 +67,9 @@ public class OrderLifecycleManagerImpl implements OrderLifecycleManager {
|
||||
@Resource
|
||||
private EventLogRecorder eventLogRecorder;
|
||||
|
||||
@Resource
|
||||
private ApplicationEventPublisher applicationEventPublisher;
|
||||
|
||||
/**
|
||||
* 责任链处理器
|
||||
*/
|
||||
@@ -101,10 +109,15 @@ public class OrderLifecycleManagerImpl implements OrderLifecycleManager {
|
||||
// 4. 检查结果
|
||||
if (context.hasError()) {
|
||||
log.error("状态转换失败: orderId={}, error={}", order.getId(), context.getErrorMessage());
|
||||
publishAttempt(order, oldStatus, request, false,
|
||||
TransitionErrorCode.INVALID_TRANSITION,
|
||||
context.getErrorMessage(),
|
||||
summarizeThrowable(context.getCause()));
|
||||
return OrderTransitionResult.fail(order.getId(), context.getErrorMessage());
|
||||
}
|
||||
|
||||
log.info("状态转换成功: orderId={}, {} -> {}", order.getId(), oldStatus, request.getTargetStatus());
|
||||
publishAttempt(order, oldStatus, request, true, null, null, null);
|
||||
return OrderTransitionResult.builder()
|
||||
.success(true)
|
||||
.orderId(order.getId())
|
||||
@@ -142,6 +155,35 @@ public class OrderLifecycleManagerImpl implements OrderLifecycleManager {
|
||||
// 设置目标状态
|
||||
request.setTargetStatus(WorkOrderStatusEnum.DISPATCHED);
|
||||
|
||||
// 业务不变量:同一执行人在任一时刻最多只能有 1 条活跃工单
|
||||
// (DISPATCHED/CONFIRMED/ARRIVED)。PAUSED 不纳入——P0 打断恢复走的就是
|
||||
// PAUSED→DISPATCHED,此处放行。对命中行加 FOR UPDATE,配合 @Transactional
|
||||
// 串行化并发派发;命中则本次派发被拒,由调用方决定降级策略
|
||||
// (DispatchEngineImpl.executeDirectDispatch 会降级为入队)。
|
||||
if (request.getAssigneeId() != null) {
|
||||
java.util.List<OpsOrderDO> activeOrders = opsOrderMapper.selectActiveByAssigneeForUpdate(
|
||||
request.getAssigneeId(), request.getOrderId());
|
||||
if (!activeOrders.isEmpty()) {
|
||||
OpsOrderDO head = activeOrders.get(0);
|
||||
String msg = "执行人已有活跃工单: orderId=" + head.getId() + ", status=" + head.getStatus();
|
||||
log.warn("派发被拒:执行人已有活跃工单: assigneeId={}, requestOrderId={}, activeCount={}, sampleOrderId={}, sampleStatus={}",
|
||||
request.getAssigneeId(), request.getOrderId(),
|
||||
activeOrders.size(), head.getId(), head.getStatus());
|
||||
|
||||
// 审计:记录"派发被拒"尝试,AFTER_COMMIT 监听器会写 bus_log
|
||||
OpsOrderDO subject = opsOrderMapper.selectById(request.getOrderId());
|
||||
WorkOrderStatusEnum fromStatus = subject != null
|
||||
? WorkOrderStatusEnum.valueOf(subject.getStatus()) : null;
|
||||
publishAttempt(subject != null ? subject : head, fromStatus, request, false,
|
||||
TransitionErrorCode.ASSIGNEE_HAS_ACTIVE_ORDER, msg, null);
|
||||
|
||||
return OrderTransitionResult.fail(
|
||||
request.getOrderId(),
|
||||
msg,
|
||||
TransitionErrorCode.ASSIGNEE_HAS_ACTIVE_ORDER);
|
||||
}
|
||||
}
|
||||
|
||||
// 派单时更新工单的 assigneeId(从 PENDING -> DISPATCHED)
|
||||
if (request.getAssigneeId() != null) {
|
||||
OpsOrderDO order = opsOrderMapper.selectById(request.getOrderId());
|
||||
@@ -188,17 +230,22 @@ public class OrderLifecycleManagerImpl implements OrderLifecycleManager {
|
||||
public void resumeOrder(Long orderId, Long operatorId) {
|
||||
log.info("开始恢复工单: orderId={}, operatorId={}", orderId, operatorId);
|
||||
|
||||
// 构建请求
|
||||
// 取出工单自身的 assigneeId 透传给 dispatch,使其 FOR UPDATE 不变量检查生效——
|
||||
// 否则 P0 恢复与并发派发竞争时可能再出现"同一 assignee 两条 DISPATCHED"。
|
||||
// assigneeId == null 的异常态(工单已卸人)下 dispatch 会跳过该检查,行为退化为原 transition。
|
||||
OpsOrderDO order = opsOrderMapper.selectById(orderId);
|
||||
Long assigneeId = order != null ? order.getAssigneeId() : null;
|
||||
|
||||
OrderTransitionRequest request = OrderTransitionRequest.builder()
|
||||
.orderId(orderId)
|
||||
.targetStatus(WorkOrderStatusEnum.DISPATCHED)
|
||||
.assigneeId(assigneeId)
|
||||
.operatorType(OperatorTypeEnum.CLEANER)
|
||||
.operatorId(operatorId)
|
||||
.reason("恢复工单")
|
||||
.build();
|
||||
|
||||
// 执行状态转换
|
||||
OrderTransitionResult result = transition(request);
|
||||
OrderTransitionResult result = dispatch(request);
|
||||
|
||||
if (!result.isSuccess()) {
|
||||
throw new IllegalStateException("恢复工单失败: " + result.getMessage());
|
||||
@@ -409,4 +456,49 @@ public class OrderLifecycleManagerImpl implements OrderLifecycleManager {
|
||||
|| WorkOrderStatusEnum.ARRIVED == status;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布状态转换尝试事件,覆盖成功、普通失败、并发冲突三种情况。
|
||||
* 订阅方 {@code OrderTransitionAuditListener} 在 AFTER_COMMIT/AFTER_ROLLBACK
|
||||
* 阶段落 bus_log,保证事务回滚不断链。
|
||||
*/
|
||||
private void publishAttempt(OpsOrderDO order, WorkOrderStatusEnum fromStatus,
|
||||
OrderTransitionRequest request, boolean success,
|
||||
TransitionErrorCode errorCode, String errorMessage,
|
||||
String causeSummary) {
|
||||
try {
|
||||
OrderTransitionAttemptedEvent event = OrderTransitionAttemptedEvent.builder()
|
||||
.orderId(order != null ? order.getId() : request.getOrderId())
|
||||
.orderType(order != null ? order.getOrderType() : null)
|
||||
.orderCode(order != null ? order.getOrderCode() : null)
|
||||
.fromStatus(fromStatus)
|
||||
.targetStatus(request.getTargetStatus())
|
||||
.assigneeId(request.getAssigneeId())
|
||||
.operatorType(request.getOperatorType())
|
||||
.operatorId(request.getOperatorId())
|
||||
.reason(request.getReason())
|
||||
.success(success)
|
||||
.errorCode(errorCode)
|
||||
.errorMessage(errorMessage)
|
||||
.causeSummary(causeSummary)
|
||||
.attemptedAt(LocalDateTime.now())
|
||||
.build();
|
||||
applicationEventPublisher.publishEvent(event);
|
||||
} catch (Exception e) {
|
||||
// 审计事件发布失败不应影响主流程
|
||||
log.error("发布转换尝试事件失败: orderId={}, targetStatus={}",
|
||||
request.getOrderId(), request.getTargetStatus(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 摘要异常:只保留类名 + message,不带堆栈,防止 bus_log 爆炸。
|
||||
*/
|
||||
private String summarizeThrowable(Throwable t) {
|
||||
if (t == null) {
|
||||
return null;
|
||||
}
|
||||
String msg = t.getMessage();
|
||||
return t.getClass().getSimpleName() + (msg != null ? ": " + msg : "");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
package com.viewsh.module.ops.core.lifecycle.audit;
|
||||
|
||||
import com.viewsh.module.ops.core.event.OrderTransitionAttemptedEvent;
|
||||
import com.viewsh.module.ops.core.lifecycle.model.TransitionErrorCode;
|
||||
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.enumeration.LogModule;
|
||||
import com.viewsh.module.ops.infrastructure.log.enumeration.LogType;
|
||||
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;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.event.TransactionPhase;
|
||||
import org.springframework.transaction.event.TransactionalEventListener;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 工单状态转换尝试审计监听器。
|
||||
* <p>
|
||||
* 闭环设计:
|
||||
* <ul>
|
||||
* <li><b>AFTER_COMMIT</b>:主事务成功提交,按事件本身的 success 标志写 bus_log。</li>
|
||||
* <li><b>AFTER_ROLLBACK</b>:主事务已回滚——事件里的数据(ops_order_event 等)全部消失。
|
||||
* 此时必须新开一个独立事务写 bus_log,否则审计链断裂。</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* 字段归位:
|
||||
* <ul>
|
||||
* <li>{@code eventLevel}:成功=INFO;失败=WARN(冲突被拒)或 ERROR(状态机异常)</li>
|
||||
* <li>{@code eventDomain}:统一用 DISPATCH(派发域),便于运维按域聚合</li>
|
||||
* <li>{@code eventType}:成功→业务 LogType(如 ORDER_DISPATCHED);失败→TRANSITION_FAILED
|
||||
* 或 DISPATCH_REJECTED</li>
|
||||
* <li>{@code eventPayload}:errorCode / fromStatus / targetStatus / operatorType / reason / cause</li>
|
||||
* </ul>
|
||||
*
|
||||
* @author lzh
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class OrderTransitionAuditListener {
|
||||
|
||||
@Resource
|
||||
private EventLogRecorder eventLogRecorder;
|
||||
|
||||
/**
|
||||
* 主事务已提交:照事件声明写一条审计日志。
|
||||
* <p>
|
||||
* fallbackExecution=true:在无事务上下文时也执行(如测试、跨线程补写场景)。
|
||||
*/
|
||||
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true)
|
||||
public void onAfterCommit(OrderTransitionAttemptedEvent event) {
|
||||
try {
|
||||
eventLogRecorder.recordSync(toRecord(event, /*rolledBack=*/false));
|
||||
} catch (Exception e) {
|
||||
log.error("[TransitionAudit] AFTER_COMMIT 写 bus_log 失败: orderId={}, success={}, errorCode={}",
|
||||
event.getOrderId(), event.isSuccess(), event.getErrorCode(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 主事务已回滚:无论事件里声称 success 与否,这次"尝试"都**实际未落库**。
|
||||
* 必须开独立事务写 bus_log,否则日志也会因同事务回滚而丢失。
|
||||
*/
|
||||
@TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
|
||||
public void onAfterRollback(OrderTransitionAttemptedEvent event) {
|
||||
writeRollbackAudit(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入"事务已回滚"的审计记录。
|
||||
* <p>
|
||||
* 不加 @Transactional:AFTER_ROLLBACK 阶段主事务已彻底结束,当前线程无活跃事务;
|
||||
* 且本方法由 onAfterRollback 自调用,Spring 代理不会拦截,加注解也是死注解。
|
||||
* 实际行为:eventLogRecorder.recordSync 的 insert 在 auto-commit 模式下单条提交,
|
||||
* 失败只丢这一行审计、不影响主业务(主业务早已回滚并报错给调用方)。
|
||||
*/
|
||||
public void writeRollbackAudit(OrderTransitionAttemptedEvent event) {
|
||||
try {
|
||||
eventLogRecorder.recordSync(toRecord(event, /*rolledBack=*/true));
|
||||
} catch (Exception e) {
|
||||
log.error("[TransitionAudit] AFTER_ROLLBACK 写 bus_log 失败: orderId={}, targetStatus={}",
|
||||
event.getOrderId(), event.getTargetStatus(), e);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 私有映射方法 ====================
|
||||
|
||||
private EventLogRecord toRecord(OrderTransitionAttemptedEvent event, boolean rolledBack) {
|
||||
// rolledBack=true 时强制视为失败:即便发布时声明 success=true,
|
||||
// 事务 rollback 说明写入未真正生效。
|
||||
boolean success = event.isSuccess() && !rolledBack;
|
||||
|
||||
EventLevel level = success ? EventLevel.INFO
|
||||
: (event.getErrorCode() == TransitionErrorCode.ASSIGNEE_HAS_ACTIVE_ORDER
|
||||
? EventLevel.WARN : EventLevel.ERROR);
|
||||
|
||||
String eventTypeCode = resolveEventTypeCode(event, success, rolledBack);
|
||||
|
||||
Map<String, Object> payload = new HashMap<>();
|
||||
payload.put("fromStatus", event.getFromStatus() != null ? event.getFromStatus().getStatus() : null);
|
||||
payload.put("targetStatus", event.getTargetStatus() != null ? event.getTargetStatus().getStatus() : null);
|
||||
payload.put("operatorType", event.getOperatorType() != null ? event.getOperatorType().getType() : null);
|
||||
payload.put("reason", event.getReason());
|
||||
payload.put("success", success);
|
||||
payload.put("rolledBack", rolledBack);
|
||||
if (event.getErrorCode() != null) {
|
||||
payload.put("errorCode", event.getErrorCode().name());
|
||||
}
|
||||
if (event.getErrorMessage() != null) {
|
||||
payload.put("errorMessage", event.getErrorMessage());
|
||||
}
|
||||
if (event.getCauseSummary() != null) {
|
||||
payload.put("cause", event.getCauseSummary());
|
||||
}
|
||||
if (event.getOrderCode() != null) {
|
||||
payload.put("orderCode", event.getOrderCode());
|
||||
}
|
||||
|
||||
String message = buildMessage(event, success, rolledBack);
|
||||
|
||||
return EventLogRecord.builder()
|
||||
.module(LogModule.fromOrderType(event.getOrderType()))
|
||||
.domain(EventDomain.DISPATCH)
|
||||
.eventType(eventTypeCode)
|
||||
.level(level)
|
||||
.message(message)
|
||||
.targetId(event.getOrderId())
|
||||
.targetType("order")
|
||||
.deviceId(event.getAssigneeId())
|
||||
.personId(event.getOperatorId())
|
||||
.payload(payload)
|
||||
.eventTime(event.getAttemptedAt())
|
||||
.build();
|
||||
}
|
||||
|
||||
private String resolveEventTypeCode(OrderTransitionAttemptedEvent event, boolean success, boolean rolledBack) {
|
||||
if (!success && event.getErrorCode() == TransitionErrorCode.ASSIGNEE_HAS_ACTIVE_ORDER) {
|
||||
return LogType.DISPATCH_REJECTED.getCode();
|
||||
}
|
||||
if (!success) {
|
||||
return LogType.TRANSITION_FAILED.getCode();
|
||||
}
|
||||
// 成功场景:按目标状态映射到业务 LogType;ops_order_event 已有时间轴,
|
||||
// 这里 bus_log 仅作宽表镜像,便于运维按 domain/module 聚合查询。
|
||||
if (event.getTargetStatus() == null) {
|
||||
return LogType.SYSTEM_EVENT.getCode();
|
||||
}
|
||||
return switch (event.getTargetStatus()) {
|
||||
case QUEUED -> LogType.ORDER_QUEUED.getCode();
|
||||
case DISPATCHED -> LogType.ORDER_DISPATCHED.getCode();
|
||||
case CONFIRMED -> LogType.ORDER_CONFIRM.getCode();
|
||||
case ARRIVED -> LogType.ORDER_ARRIVED.getCode();
|
||||
case PAUSED -> LogType.ORDER_PAUSED.getCode();
|
||||
case COMPLETED -> LogType.ORDER_COMPLETED.getCode();
|
||||
case CANCELLED -> LogType.ORDER_CANCELLED.getCode();
|
||||
default -> LogType.SYSTEM_EVENT.getCode();
|
||||
};
|
||||
}
|
||||
|
||||
private String buildMessage(OrderTransitionAttemptedEvent event, boolean success, boolean rolledBack) {
|
||||
String from = event.getFromStatus() != null ? event.getFromStatus().getStatus() : "?";
|
||||
String to = event.getTargetStatus() != null ? event.getTargetStatus().getStatus() : "?";
|
||||
if (success) {
|
||||
return String.format("状态转换成功: %s -> %s", from, to);
|
||||
}
|
||||
String prefix = rolledBack ? "状态转换回滚" : "状态转换失败";
|
||||
String detail = event.getErrorMessage() != null ? event.getErrorMessage() : "";
|
||||
return String.format("%s: %s -> %s %s", prefix, from, to, detail).trim();
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,14 @@ public class OrderTransitionResult {
|
||||
*/
|
||||
private Long queueId;
|
||||
|
||||
/**
|
||||
* 失败错误码(仅 success=false 时有值)
|
||||
* <p>
|
||||
* 调用方可据此区分需降级的失败(如 ASSIGNEE_HAS_ACTIVE_ORDER)与硬失败,
|
||||
* 未显式设置时默认为 {@link TransitionErrorCode#OTHER}。
|
||||
*/
|
||||
private TransitionErrorCode errorCode;
|
||||
|
||||
/**
|
||||
* 成功结果
|
||||
*/
|
||||
@@ -81,6 +89,7 @@ public class OrderTransitionResult {
|
||||
return OrderTransitionResult.builder()
|
||||
.success(false)
|
||||
.message(message)
|
||||
.errorCode(TransitionErrorCode.OTHER)
|
||||
.build();
|
||||
}
|
||||
|
||||
@@ -92,6 +101,19 @@ public class OrderTransitionResult {
|
||||
.success(false)
|
||||
.orderId(orderId)
|
||||
.message(message)
|
||||
.errorCode(TransitionErrorCode.OTHER)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 失败结果(带工单ID 和错误码)
|
||||
*/
|
||||
public static OrderTransitionResult fail(Long orderId, String message, TransitionErrorCode errorCode) {
|
||||
return OrderTransitionResult.builder()
|
||||
.success(false)
|
||||
.orderId(orderId)
|
||||
.message(message)
|
||||
.errorCode(errorCode)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.viewsh.module.ops.core.lifecycle.model;
|
||||
|
||||
/**
|
||||
* 状态转换失败的错误码
|
||||
* <p>
|
||||
* 用于调用方区分可恢复/需降级的失败场景(如并发冲突)与真正的硬失败(状态机非法转换等),
|
||||
* 避免把"可降级"的结果误当成硬错误直接向用户暴露。
|
||||
*
|
||||
* @author lzh
|
||||
*/
|
||||
public enum TransitionErrorCode {
|
||||
|
||||
/**
|
||||
* 执行人已有活跃工单(DISPATCHED/CONFIRMED/ARRIVED),不应再派发。
|
||||
* <p>
|
||||
* 发生在 OrderLifecycleManager.dispatch 入口的 FOR UPDATE 兜底检查命中时。
|
||||
* 调用方应将工单降级到 QUEUED(入队等待下一轮动态派发),避免 PENDING 状态悬空。
|
||||
*/
|
||||
ASSIGNEE_HAS_ACTIVE_ORDER,
|
||||
|
||||
/**
|
||||
* 状态机不允许此转换(非法的状态流转)
|
||||
*/
|
||||
INVALID_TRANSITION,
|
||||
|
||||
/**
|
||||
* 工单不存在
|
||||
*/
|
||||
ORDER_NOT_FOUND,
|
||||
|
||||
/**
|
||||
* 其他失败(无特定归类)
|
||||
*/
|
||||
OTHER;
|
||||
}
|
||||
@@ -54,7 +54,7 @@ public interface OpsOrderQueueMapper extends BaseMapperX<OpsOrderQueueDO> {
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据用户ID查询队列列表
|
||||
* 根据用户ID查询队列列表(含历史 REMOVED 记录,通常用于审计/统计)
|
||||
*/
|
||||
default List<OpsOrderQueueDO> selectListByUserId(Long userId) {
|
||||
return selectList(new LambdaQueryWrapperX<OpsOrderQueueDO>()
|
||||
@@ -62,6 +62,19 @@ public interface OpsOrderQueueMapper extends BaseMapperX<OpsOrderQueueDO> {
|
||||
.orderByDesc(OpsOrderQueueDO::getEnqueueTime));
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据用户ID查询活跃队列列表(仅 WAITING/PROCESSING/PAUSED,排除 REMOVED/已终态)
|
||||
* <p>
|
||||
* 同步到 Redis、计算队列长度、查询当前任务等场景应走此方法,避免
|
||||
* 将历史 REMOVED 记录同步到 Redis 造成 ZSet / Hash 膨胀。
|
||||
*/
|
||||
default List<OpsOrderQueueDO> selectActiveListByUserId(Long userId) {
|
||||
return selectList(new LambdaQueryWrapperX<OpsOrderQueueDO>()
|
||||
.eq(OpsOrderQueueDO::getUserId, userId)
|
||||
.in(OpsOrderQueueDO::getQueueStatus, "WAITING", "PROCESSING", "PAUSED")
|
||||
.orderByDesc(OpsOrderQueueDO::getEnqueueTime));
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据用户ID和状态查询队列列表
|
||||
* 用于强制从 MySQL 读取最新数据
|
||||
|
||||
@@ -92,6 +92,80 @@ public interface OpsOrderMapper extends BaseMapperX<OpsOrderDO> {
|
||||
.last("LIMIT 1"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询执行人名下尚未结束的工单(DISPATCHED/CONFIRMED/ARRIVED/PAUSED)
|
||||
* <p>
|
||||
* 用于 autoDispatchNext 等调度入口的空闲校验:若该执行人仍挂着活跃工单,
|
||||
* 则不应再派发新任务,避免"越清越多"的级联派发。
|
||||
*
|
||||
* @param assigneeId 执行人ID(工牌设备ID)
|
||||
* @param excludeOrderId 需要排除的工单ID(通常是刚完成/取消触发本次调度的工单),可传 null
|
||||
* @return 活跃工单列表,按创建时间升序
|
||||
*/
|
||||
default List<OpsOrderDO> selectActiveByAssignee(Long assigneeId, Long excludeOrderId) {
|
||||
return selectList(new LambdaQueryWrapperX<OpsOrderDO>()
|
||||
.eq(OpsOrderDO::getAssigneeId, assigneeId)
|
||||
.in(OpsOrderDO::getStatus,
|
||||
WorkOrderStatusEnum.DISPATCHED.getStatus(),
|
||||
WorkOrderStatusEnum.CONFIRMED.getStatus(),
|
||||
WorkOrderStatusEnum.ARRIVED.getStatus(),
|
||||
WorkOrderStatusEnum.PAUSED.getStatus())
|
||||
.ne(excludeOrderId != null, OpsOrderDO::getId, excludeOrderId)
|
||||
.orderByAsc(OpsOrderDO::getCreateTime));
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询执行人名下"正在执行"的工单,并对命中行加行锁(SELECT ... FOR UPDATE)
|
||||
* <p>
|
||||
* 与 {@link #selectActiveByAssignee} 的区别:
|
||||
* <ul>
|
||||
* <li><b>不含 PAUSED</b>——PAUSED 代表 P0 打断后挂起的旧任务,不占用"当前时间片",
|
||||
* 派发时(如 P0 结束后恢复)不应被它阻塞</li>
|
||||
* <li>结果行加 FOR UPDATE 排他锁,用于 dispatch 入口做业务不变量校验:
|
||||
* "同一执行人在任一时刻最多只能有 1 条活跃工单"。</li>
|
||||
* </ul>
|
||||
* 必须在事务中调用,否则锁无意义。
|
||||
*
|
||||
* @param assigneeId 执行人ID
|
||||
* @param excludeOrderId 排除的工单ID(通常是本次正在派发的工单本身)
|
||||
* @return 命中的活跃工单列表(通常空列表表示可派发)
|
||||
*/
|
||||
default List<OpsOrderDO> selectActiveByAssigneeForUpdate(Long assigneeId, Long excludeOrderId) {
|
||||
return selectList(new LambdaQueryWrapperX<OpsOrderDO>()
|
||||
.eq(OpsOrderDO::getAssigneeId, assigneeId)
|
||||
.in(OpsOrderDO::getStatus,
|
||||
WorkOrderStatusEnum.DISPATCHED.getStatus(),
|
||||
WorkOrderStatusEnum.CONFIRMED.getStatus(),
|
||||
WorkOrderStatusEnum.ARRIVED.getStatus())
|
||||
.ne(excludeOrderId != null, OpsOrderDO::getId, excludeOrderId)
|
||||
.orderByAsc(OpsOrderDO::getCreateTime)
|
||||
.last("FOR UPDATE"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询执行人最近一条已完成工单的区域(用于楼层基准兜底)
|
||||
* <p>
|
||||
* 用途:{@code OrderQueueServiceEnhanced.resolveBaselineAreaId} 的二级兜底。
|
||||
* 当执行人当前没有 PROCESSING 工单时(短暂空闲),用最近完成的那一单的
|
||||
* 区域作为"物理位置推断",保证楼层差评分在空闲期仍然生效。
|
||||
* <p>
|
||||
* 时间窗:通过 {@code since} 过滤,超过窗口仍空闲则认为轨迹失效,
|
||||
* 返回 null 让调用方降级到更外层的兜底(fallbackAreaId 或无楼层模式)。
|
||||
*
|
||||
* @param assigneeId 执行人ID
|
||||
* @param since 只考虑 updateTime 晚于此时间的工单(如 now - 24h)
|
||||
* @return 最近一条 COMPLETED 工单的 areaId;无匹配返回 null
|
||||
*/
|
||||
default Long selectLatestCompletedAreaIdByAssignee(Long assigneeId, LocalDateTime since) {
|
||||
OpsOrderDO order = selectOne(new LambdaQueryWrapperX<OpsOrderDO>()
|
||||
.eq(OpsOrderDO::getAssigneeId, assigneeId)
|
||||
.eq(OpsOrderDO::getStatus, WorkOrderStatusEnum.COMPLETED.getStatus())
|
||||
.ge(since != null, OpsOrderDO::getUpdateTime, since)
|
||||
.orderByDesc(OpsOrderDO::getUpdateTime)
|
||||
.last("LIMIT 1"));
|
||||
return order != null ? order.getAreaId() : null;
|
||||
}
|
||||
|
||||
// ==================== 统计聚合查询 ====================
|
||||
|
||||
/**
|
||||
|
||||
@@ -47,7 +47,14 @@ public enum LogType {
|
||||
COMPLETE_SUPPRESSED_INVALID("COMPLETE_SUPPRESSED_INVALID", "作业时长不足抑制"),
|
||||
BEACON_COMPLETE_REQUESTED("BEACON_COMPLETE_REQUESTED", "信号丢失自动完成请求"),
|
||||
TTS_REQUEST("TTS_REQUEST", "语音播报请求"),
|
||||
ARRIVE_REJECTED("ARRIVE_REJECTED", "到岗请求被拒绝");
|
||||
ARRIVE_REJECTED("ARRIVE_REJECTED", "到岗请求被拒绝"),
|
||||
|
||||
// ========== 状态机转换闭环审计 ==========
|
||||
|
||||
/** 状态转换尝试失败(状态机异常、handler 抛错等) */
|
||||
TRANSITION_FAILED("TRANSITION_FAILED", "状态转换失败"),
|
||||
/** 派发被 FOR UPDATE 拒绝(同 assignee 已有活跃工单) */
|
||||
DISPATCH_REJECTED("DISPATCH_REJECTED", "派发被拒绝");
|
||||
|
||||
private static final Map<String, LogType> CODE_MAP = new HashMap<>();
|
||||
|
||||
|
||||
@@ -25,6 +25,17 @@ public interface EventLogRecorder {
|
||||
*/
|
||||
void recordAsync(EventLogRecord record);
|
||||
|
||||
/**
|
||||
* 记录事件日志(同步)
|
||||
* <p>
|
||||
* 需要确保日志真正落库的场景使用(如 AFTER_COMMIT 审计、事务回滚场景补写)。
|
||||
* 调用方负责事务边界:本方法内部不开启事务,MyBatis 的 insert 会按当前线程的
|
||||
* 事务上下文执行;若无活跃事务则自动单条提交。
|
||||
*
|
||||
* @param record 日志记录
|
||||
*/
|
||||
void recordSync(EventLogRecord record);
|
||||
|
||||
// ==================== 便捷方法:按级别记录 ====================
|
||||
|
||||
/**
|
||||
|
||||
@@ -58,6 +58,7 @@ public class EventLogRecorderImpl implements EventLogRecorder {
|
||||
* <p>
|
||||
* 用于需要确认日志写入成功的场景(如测试、关键业务)
|
||||
*/
|
||||
@Override
|
||||
public void recordSync(EventLogRecord record) {
|
||||
doRecord(record);
|
||||
}
|
||||
|
||||
@@ -115,6 +115,9 @@ public class OrderQueueServiceEnhanced implements OrderQueueService {
|
||||
// TODO: 触发紧急派单流程(在派单引擎中实现)
|
||||
}
|
||||
|
||||
// 5. 事务提交后按全局楼层重排一次:新入队工单立即按楼层差参与排序,不等下一次 rebuild
|
||||
triggerQueueRebuildAfterCommit(userId, null);
|
||||
|
||||
return queueDO.getId();
|
||||
}
|
||||
|
||||
@@ -467,7 +470,8 @@ public class OrderQueueServiceEnhanced implements OrderQueueService {
|
||||
}
|
||||
|
||||
// 2. Redis 未命中,从 MySQL 获取并同步到 Redis
|
||||
List<OpsOrderQueueDO> mysqlList = orderQueueMapper.selectListByUserId(userId);
|
||||
// 只同步活跃态(WAITING/PROCESSING/PAUSED),排除 REMOVED 历史记录,避免 Redis 膨胀
|
||||
List<OpsOrderQueueDO> mysqlList = orderQueueMapper.selectActiveListByUserId(userId);
|
||||
if (mysqlList != null && !mysqlList.isEmpty()) {
|
||||
// 同步到 Redis
|
||||
List<OrderQueueDTO> dtoList = convertToDTO(mysqlList);
|
||||
@@ -511,10 +515,31 @@ public class OrderQueueServiceEnhanced implements OrderQueueService {
|
||||
Integer baseFloorNo = resolveFloorNo(baselineAreaId);
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
|
||||
// 批量装载 orders + areas,消除 N+1:100 条 WAITING 从 200 次 SELECT 降为 2 次。
|
||||
List<Long> orderIds = waitingQueues.stream()
|
||||
.map(OpsOrderQueueDO::getOpsOrderId)
|
||||
.filter(Objects::nonNull)
|
||||
.distinct()
|
||||
.collect(Collectors.toList());
|
||||
Map<Long, Long> orderIdToAreaId = orderIds.isEmpty()
|
||||
? Collections.emptyMap()
|
||||
: orderMapper.selectBatchIds(orderIds).stream()
|
||||
.filter(o -> o.getAreaId() != null)
|
||||
.collect(Collectors.toMap(OpsOrderDO::getId, OpsOrderDO::getAreaId,
|
||||
(a, b) -> a));
|
||||
List<Long> areaIds = orderIdToAreaId.values().stream().distinct().collect(Collectors.toList());
|
||||
Map<Long, Integer> areaIdToFloorNo = areaIds.isEmpty()
|
||||
? Collections.emptyMap()
|
||||
: areaMapper.selectBatchIds(areaIds).stream()
|
||||
.filter(a -> a.getFloorNo() != null)
|
||||
.collect(Collectors.toMap(OpsBusAreaDO::getId, OpsBusAreaDO::getFloorNo,
|
||||
(a, b) -> a));
|
||||
|
||||
List<OrderQueueDTO> rebuiltTasks = new ArrayList<>(waitingQueues.size());
|
||||
for (OpsOrderQueueDO queueDO : waitingQueues) {
|
||||
OrderQueueDTO dto = convertToDTO(queueDO);
|
||||
Integer targetFloorNo = resolveFloorNo(resolveOrderAreaId(queueDO.getOpsOrderId()));
|
||||
Long targetAreaId = orderIdToAreaId.get(queueDO.getOpsOrderId());
|
||||
Integer targetFloorNo = targetAreaId != null ? areaIdToFloorNo.get(targetAreaId) : null;
|
||||
QueueScoreResult result = queueScoreCalculator.calculate(QueueScoreContext.builder()
|
||||
.priority(queueDO.getPriority())
|
||||
.baseFloorNo(baseFloorNo)
|
||||
@@ -739,7 +764,17 @@ public class OrderQueueServiceEnhanced implements OrderQueueService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析楼层基准区域(三级兜底)
|
||||
* <ol>
|
||||
* <li>当前 PROCESSING 工单的区域——表示“正在做的楼层”</li>
|
||||
* <li>最近 24 小时内已完成工单的区域——投射保洁员最近的物理位置</li>
|
||||
* <li>调用方显式传入的 {@code fallbackAreaId}(如 autoDispatchNext 传的 completedOrder.areaId)</li>
|
||||
* </ol>
|
||||
* 都未命中则返回 null,本次排序降级为无楼层模式。
|
||||
*/
|
||||
private Long resolveBaselineAreaId(Long userId, Long fallbackAreaId) {
|
||||
// 一级:当前正在执行的工单
|
||||
OpsOrderQueueDO processingQueue = orderQueueMapper.selectCurrentExecutingByUserId(userId);
|
||||
if (processingQueue != null) {
|
||||
Long processingAreaId = resolveOrderAreaId(processingQueue.getOpsOrderId());
|
||||
@@ -747,6 +782,13 @@ public class OrderQueueServiceEnhanced implements OrderQueueService {
|
||||
return processingAreaId;
|
||||
}
|
||||
}
|
||||
// 二级:最近 24 小时内的已完成工单,推断保洁员当前物理位置
|
||||
Long recentAreaId = orderMapper.selectLatestCompletedAreaIdByAssignee(
|
||||
userId, LocalDateTime.now().minusHours(24));
|
||||
if (recentAreaId != null) {
|
||||
return recentAreaId;
|
||||
}
|
||||
// 三级:调用方提示的区域(可为 null)
|
||||
return fallbackAreaId;
|
||||
}
|
||||
|
||||
@@ -764,7 +806,8 @@ public class OrderQueueServiceEnhanced implements OrderQueueService {
|
||||
}
|
||||
|
||||
private void syncUserQueueToRedis(Long userId, List<OrderQueueDTO> rebuiltWaitingTasks) {
|
||||
List<OpsOrderQueueDO> queues = orderQueueMapper.selectListByUserId(userId);
|
||||
// 只同步活跃态(WAITING/PROCESSING/PAUSED),避免把历史 REMOVED 记录回写 Redis ZSet/Hash
|
||||
List<OpsOrderQueueDO> queues = orderQueueMapper.selectActiveListByUserId(userId);
|
||||
if (queues == null || queues.isEmpty()) {
|
||||
redisQueueService.clearQueue(userId);
|
||||
return;
|
||||
@@ -794,6 +837,21 @@ public class OrderQueueServiceEnhanced implements OrderQueueService {
|
||||
redisQueueService.batchEnqueue(queueDTOs);
|
||||
}
|
||||
|
||||
/**
|
||||
* 在当前事务提交后触发一次等待队列重算。
|
||||
* <p>
|
||||
* <b>事务边界说明</b>:本方法在 afterCommit 阶段(即外层事务已提交)自调用
|
||||
* {@link #rebuildWaitingTasksByUserId(Long, Long)},此时:
|
||||
* <ul>
|
||||
* <li>当前线程不在任何事务中(主事务刚提交完)</li>
|
||||
* <li>自调用绕过 Spring 代理,rebuild 方法上的 @Transactional 不生效</li>
|
||||
* <li>实际运行在 auto-commit 模式:每个 updateById 独立提交</li>
|
||||
* </ul>
|
||||
* <b>后果</b>:rebuild 中途抛异常时 MySQL 可能半更新、Redis 可能部分写入,
|
||||
* 不强一致但最终一致——下一次 enqueue 会再触发一次完整 rebuild 自愈。
|
||||
* 对“队列排序”这类可重放数据可以接受;若未来改为影响 MySQL 外表的写入,
|
||||
* 需要把 rebuild 抽到独立 bean,用代理调用走新事务。
|
||||
*/
|
||||
private void triggerQueueRebuildAfterCommit(Long userId, Long fallbackAreaId) {
|
||||
Runnable rebuildAction = () -> {
|
||||
try {
|
||||
|
||||
@@ -9,7 +9,11 @@ import java.time.LocalDateTime;
|
||||
public class QueueScoreCalculator {
|
||||
|
||||
static final int PRIORITY_WEIGHT = 1500;
|
||||
static final int FLOOR_WEIGHT = 60;
|
||||
/**
|
||||
* 楼层差权重。10 层封顶 × 100 = 1000,大于 aging 上限 720,实现"强楼层优先":
|
||||
* 等满 4 小时(aging 上限)的任务也不会反超更近楼层的同优先级任务。
|
||||
*/
|
||||
static final int FLOOR_WEIGHT = 100;
|
||||
static final int AGING_WEIGHT = 3;
|
||||
static final int MAX_FLOOR_DIFF = 10;
|
||||
static final int MAX_AGING_MINUTES = 240;
|
||||
@@ -22,11 +26,11 @@ public class QueueScoreCalculator {
|
||||
Integer targetFloorNo = context.getTargetFloorNo();
|
||||
Integer floorDiff = null;
|
||||
int floorDiffScore = 0;
|
||||
// 语义对称:只要 baseFloor 或 targetFloor 任一缺失,就视为"信息不足",不参与楼层排序(score=0)。
|
||||
// 旧逻辑会在"有 base 无 target"时打 +600 罚分,导致同一工单在保洁员忙碌/空闲时排序不单调。
|
||||
if (baseFloorNo != null && targetFloorNo != null) {
|
||||
floorDiff = Math.abs(targetFloorNo - baseFloorNo);
|
||||
floorDiffScore = Math.min(floorDiff, MAX_FLOOR_DIFF) * FLOOR_WEIGHT;
|
||||
} else if (baseFloorNo != null) {
|
||||
floorDiffScore = MAX_FLOOR_DIFF * FLOOR_WEIGHT;
|
||||
}
|
||||
|
||||
long waitMinutes = 0;
|
||||
|
||||
Reference in New Issue
Block a user