diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/core/event/OrderTransitionAttemptedEvent.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/core/event/OrderTransitionAttemptedEvent.java new file mode 100644 index 00000000..6c3f112b --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/core/event/OrderTransitionAttemptedEvent.java @@ -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; + +/** + * 工单状态转换"尝试"领域事件 + *

+ * 与 {@link OrderStateChangedEvent} 的区别: + *

+ * 事务边界: + * + * + * @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; + + /** + * 发布时的"声明结果"。 + *

+ * 注意:这是发布瞬间的判断;如果后续 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; +} diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/core/lifecycle/OrderLifecycleManagerImpl.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/core/lifecycle/OrderLifecycleManagerImpl.java index d9d4e104..e9dce59b 100644 --- a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/core/lifecycle/OrderLifecycleManagerImpl.java +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/core/lifecycle/OrderLifecycleManagerImpl.java @@ -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; @@ -8,6 +9,9 @@ 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; @@ -63,6 +67,9 @@ public class OrderLifecycleManagerImpl implements OrderLifecycleManager { @Resource private EventLogRecorder eventLogRecorder; + @Resource + private ApplicationEventPublisher applicationEventPublisher; + /** * 责任链处理器 */ @@ -102,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()) @@ -153,12 +165,21 @@ public class OrderLifecycleManagerImpl implements OrderLifecycleManager { 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(), - "执行人已有活跃工单: orderId=" + head.getId() + ", status=" + head.getStatus(), + msg, TransitionErrorCode.ASSIGNEE_HAS_ACTIVE_ORDER); } } @@ -430,4 +451,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 : ""); + } + } diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/core/lifecycle/audit/OrderTransitionAuditListener.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/core/lifecycle/audit/OrderTransitionAuditListener.java new file mode 100644 index 00000000..4d6d13ca --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/core/lifecycle/audit/OrderTransitionAuditListener.java @@ -0,0 +1,174 @@ +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.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import java.util.HashMap; +import java.util.Map; + +/** + * 工单状态转换尝试审计监听器。 + *

+ * 闭环设计: + *

+ *

+ * 字段归位: + *

+ * + * @author lzh + */ +@Slf4j +@Component +public class OrderTransitionAuditListener { + + @Resource + private EventLogRecorder eventLogRecorder; + + /** + * 主事务已提交:照事件声明写一条审计日志。 + *

+ * 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); + } + + /** + * 独立事务写入失败审计。 + *

+ * AFTER_ROLLBACK 触发时主事务已结束,此处即便用 REQUIRES_NEW 也不会遇到嵌套锁; + * 失败本身只是单行 insert,不再对其他表加锁。 + */ + @Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class) + 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 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(); + } +} diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/log/enumeration/LogType.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/log/enumeration/LogType.java index aba71be4..9e5584f7 100644 --- a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/log/enumeration/LogType.java +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/log/enumeration/LogType.java @@ -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 CODE_MAP = new HashMap<>(); diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/log/recorder/EventLogRecorder.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/log/recorder/EventLogRecorder.java index 7440065c..f044d14a 100644 --- a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/log/recorder/EventLogRecorder.java +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/log/recorder/EventLogRecorder.java @@ -25,6 +25,17 @@ public interface EventLogRecorder { */ void recordAsync(EventLogRecord record); + /** + * 记录事件日志(同步) + *

+ * 需要确保日志真正落库的场景使用(如 AFTER_COMMIT 审计、事务回滚场景补写)。 + * 调用方负责事务边界:本方法内部不开启事务,MyBatis 的 insert 会按当前线程的 + * 事务上下文执行;若无活跃事务则自动单条提交。 + * + * @param record 日志记录 + */ + void recordSync(EventLogRecord record); + // ==================== 便捷方法:按级别记录 ==================== /** diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/log/recorder/EventLogRecorderImpl.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/log/recorder/EventLogRecorderImpl.java index ea12055b..92bab9f0 100644 --- a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/log/recorder/EventLogRecorderImpl.java +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/infrastructure/log/recorder/EventLogRecorderImpl.java @@ -58,6 +58,7 @@ public class EventLogRecorderImpl implements EventLogRecorder { *

* 用于需要确认日志写入成功的场景(如测试、关键业务) */ + @Override public void recordSync(EventLogRecord record) { doRecord(record); }