Selaa lähdekoodia

洗车订单僵死问题 — 三道防线方案

防线一:closeOrder 增加 RRpc 失败重试 + 主动查询设备回退
防线二:新增僵死订单定时对账任务,每5分钟扫描超时未结算订单兜底处理
防线三:创建订单前检查设备是否有僵死订单,有则先清理
提取 OrderSettlementService 复用结算逻辑

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
skyline 2 päivää sitten
vanhempi
säilyke
6817929df0

+ 151 - 0
car-wash-admin/src/main/java/com/kym/admin/jobs/StaleOrderReconciliationJob.java

@@ -0,0 +1,151 @@
+package com.kym.admin.jobs;
+
+import com.alibaba.fastjson2.JSON;
+import com.kym.entity.WashDevice;
+import com.kym.entity.WashOrder;
+import com.kym.entity.awoara.OrderInfo;
+import com.kym.service.OrderSettlementService;
+import com.kym.service.WashDeviceService;
+import com.kym.service.WashOrderService;
+import com.kym.service.awoara.AwoaraService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * 僵死订单对账定时任务
+ * 扫描长时间未结算的订单,主动查询设备状态进行兜底结算
+ *
+ * @author skyline
+ */
+@Slf4j
+@Component
+public class StaleOrderReconciliationJob {
+
+    private final WashOrderService washOrderService;
+    private final WashDeviceService washDeviceService;
+    private final AwoaraService awoaraService;
+    private final OrderSettlementService orderSettlementService;
+
+    /**
+     * 僵死订单阈值(秒),默认30分钟
+     */
+    @Value("${kym.stale-order-threshold-seconds:1800}")
+    private int staleOrderThresholdSeconds;
+
+    public StaleOrderReconciliationJob(WashOrderService washOrderService,
+                                       WashDeviceService washDeviceService,
+                                       AwoaraService awoaraService,
+                                       OrderSettlementService orderSettlementService) {
+        this.washOrderService = washOrderService;
+        this.washDeviceService = washDeviceService;
+        this.awoaraService = awoaraService;
+        this.orderSettlementService = orderSettlementService;
+    }
+
+    /**
+     * 每5分钟扫描一次僵死订单
+     */
+    @Scheduled(cron = "0 */5 * * * ?")
+    public void reconcileStaleOrders() {
+        log.info("僵死订单对账-开始");
+        var threshold = LocalDateTime.now().minusSeconds(staleOrderThresholdSeconds);
+
+        List<WashOrder> staleOrders = washOrderService.lambdaQuery()
+                .eq(WashOrder::getOrderStatus, WashOrder.ORDER_STATUS_开机)
+                .eq(WashOrder::getPayStatus, WashOrder.PAY_STATUS_未支付)
+                .le(WashOrder::getStartTime, threshold)
+                .list();
+
+        if (staleOrders.isEmpty()) {
+            log.info("僵死订单对账-无僵死订单");
+            return;
+        }
+
+        log.info("僵死订单对账-发现 {} 个僵死订单", staleOrders.size());
+
+        for (WashOrder order : staleOrders) {
+            try {
+                reconcileOrder(order);
+            } catch (Exception e) {
+                log.error("僵死订单对账-订单 {} 处理异常", order.getOrderId(), e);
+            }
+        }
+
+        log.info("僵死订单对账-结束");
+    }
+
+    private void reconcileOrder(WashOrder order) {
+        log.info("僵死订单对账-处理订单:{},设备:{}/{}", order.getOrderId(), order.getProductKey(), order.getDeviceName());
+
+        // 策略1:主动查询设备上的订单状态
+        try {
+            OrderInfo orderInfo = awoaraService.queryOrder(order.getProductKey(), order.getDeviceName(), order.getOrderId());
+            if (orderInfo != null && orderInfo.getClose_type() != null && !orderInfo.getClose_type().isEmpty()) {
+                log.info("僵死订单对账-订单 {} 设备已关闭,执行结算", order.getOrderId());
+                orderSettlementService.settleOrder(order, orderInfo);
+                return;
+            }
+            if (orderInfo != null && (orderInfo.getClose_type() == null || orderInfo.getClose_type().isEmpty())) {
+                log.info("僵死订单对账-订单 {} 设备仍在进行中,跳过", order.getOrderId());
+                return;
+            }
+        } catch (Exception e) {
+            log.warn("僵死订单对账-订单 {} 查询设备失败:{},尝试兜底策略", order.getOrderId(), e.getMessage());
+        }
+
+        // 策略2:设备不可达,检查设备在DB中的状态
+        var device = washDeviceService.lambdaQuery()
+                .eq(WashDevice::getProductKey, order.getProductKey())
+                .eq(WashDevice::getDeviceName, order.getDeviceName())
+                .one();
+
+        if (device != null && WashDevice.STATE_空闲.equals(device.getState())) {
+            log.info("僵死订单对账-订单 {} 设备已空闲,使用最后已知数据兜底结算", order.getOrderId());
+            settleWithLastKnownData(order);
+            return;
+        }
+
+        // 策略3:设备不可达且状态未知,标记异常等待人工处理
+        log.warn("僵死订单对账-订单 {} 无法自动处理,设备状态:{},标记异常",
+                order.getOrderId(), device != null ? device.getState() : "unknown");
+        washOrderService.lambdaUpdate()
+                .set(WashOrder::getStopReason, "僵死订单-无法自动结算,设备状态:" + (device != null ? device.getState() : "unknown"))
+                .eq(WashOrder::getId, order.getId())
+                .update();
+    }
+
+    /**
+     * 使用最后一次 order_update 保存的数据进行兜底结算
+     */
+    private void settleWithLastKnownData(WashOrder order) {
+        OrderInfo orderInfo = new OrderInfo();
+        orderInfo.setOrder_id(order.getOrderId());
+        orderInfo.setOrder_id_local(order.getOrderIdLocal());
+        orderInfo.setOpen_type(order.getOpenType());
+        orderInfo.setClose_type("reconcile");
+        orderInfo.setAmount(order.getAmount() != null ? order.getAmount() : 0);
+        orderInfo.setAmount_receivable(order.getAmountReceivable() != null ? order.getAmountReceivable() : 0);
+        orderInfo.setAmount_received(order.getAmountReceived() != null ? order.getAmountReceived() : 0);
+        orderInfo.setDiscount_money(order.getDiscountMoney() != null ? order.getDiscountMoney() : 0);
+        orderInfo.setMember_discount(order.getMemberDiscount() != null ? order.getMemberDiscount() : 100);
+        orderInfo.setPrepay_money(order.getPrepayMoney() != null ? order.getPrepayMoney() : 0);
+        orderInfo.setOperation_remain_time(order.getOperationRemainTime() != null ? order.getOperationRemainTime() : 0);
+        orderInfo.setIdle_remain_time(order.getIdleRemainTime() != null ? order.getIdleRemainTime() : 0);
+        if (order.getDetail() != null) {
+            orderInfo.setDetail(order.getDetail());
+        }
+
+        log.info("僵死订单对账-订单 {} 兜底结算数据:amount={}, amount_received={}",
+                order.getOrderId(), orderInfo.getAmount(), orderInfo.getAmount_received());
+
+        order.setStopReason("僵死订单-设备已空闲,使用最后更新数据结算");
+        washOrderService.updateById(order);
+
+        orderSettlementService.settleOrder(order, orderInfo);
+    }
+}

+ 20 - 0
car-wash-service/src/main/java/com/kym/service/OrderSettlementService.java

@@ -0,0 +1,20 @@
+package com.kym.service;
+
+import com.kym.entity.WashOrder;
+import com.kym.entity.awoara.OrderInfo;
+
+/**
+ * 订单结算服务(从 OrderCloseEventHandler 提取,供事件处理、定时任务等多入口复用)
+ *
+ * @author skyline
+ */
+public interface OrderSettlementService {
+
+    /**
+     * 执行订单结算:扣款、更新订单状态、分账、记录流水、发送模板消息
+     *
+     * @param washOrder 待结算订单(数据库记录)
+     * @param orderInfo 设备上报的订单结算信息
+     */
+    void settleOrder(WashOrder washOrder, OrderInfo orderInfo);
+}

+ 9 - 170
car-wash-service/src/main/java/com/kym/service/awoara/event/handle/OrderCloseEventHandler.java

@@ -1,22 +1,14 @@
 package com.kym.service.awoara.event.handle;
 
-import com.kym.entity.*;
+import com.kym.entity.WashOrder;
 import com.kym.entity.awoara.MessageBody;
 import com.kym.entity.awoara.OrderInfoObject;
-import com.kym.service.*;
-import com.kym.service.cache.KymCache;
-import com.kym.service.factory.DiscountStrategyFactory;
+import com.kym.service.OrderSettlementService;
+import com.kym.service.WashOrderService;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Component;
 import org.springframework.transaction.annotation.Transactional;
 
-import java.math.BigDecimal;
-import java.math.RoundingMode;
-import java.time.Duration;
-import java.time.LocalDateTime;
-import java.util.List;
-import java.util.UUID;
-
 /**
  * 关闭订单事件(订单结算)
  *
@@ -25,187 +17,34 @@ import java.util.UUID;
 @Slf4j
 @Component
 public class OrderCloseEventHandler implements AwoaraEventHandler<OrderInfoObject> {
-    private String DOMAIN;
-    private final WashOrderService washOrderService;
-    private final WalletDetailService walletDetailService;
-    private final AccountService accountService;
-    private final SplitRecordService splitRecordService;
-
-    private final MpMsgTemplateService mpMsgTemplateService;
 
+    private final WashOrderService washOrderService;
+    private final OrderSettlementService orderSettlementService;
 
-    public OrderCloseEventHandler(WashOrderService washOrderService, WalletDetailService walletDetailService,
-                                  AccountService accountService, SplitRecordService splitRecordService,
-                                  MpMsgTemplateService mpMsgTemplateService,String domain) {
-        DOMAIN = domain;
+    public OrderCloseEventHandler(WashOrderService washOrderService,
+                                  OrderSettlementService orderSettlementService) {
         this.washOrderService = washOrderService;
-        this.walletDetailService = walletDetailService;
-        this.accountService = accountService;
-        this.splitRecordService = splitRecordService;
-        this.mpMsgTemplateService = mpMsgTemplateService;
+        this.orderSettlementService = orderSettlementService;
     }
 
-
     @Override
     @Transactional
     public void handle(MessageBody<OrderInfoObject> message) {
         log.info("收到订单关闭事件");
 
-        // 订单结算
         var orderInfo = message.getPayload().getData().getOrder_info();
         log.info("订单:{},结算信息:{}", orderInfo.getOrder_id(), orderInfo);
 
-        // 订单状态、支付状态
         var washOrder = washOrderService.lambdaQuery().eq(WashOrder::getOrderId, orderInfo.getOrder_id()).one();
         if (washOrder == null) {
             log.error("订单不存在,订单号:{}", orderInfo.getOrder_id());
             return;
         }
-        // 幂等保护:已结算订单不再重复处理
         if (Integer.valueOf(WashOrder.PAY_STATUS_已支付).equals(washOrder.getPayStatus())) {
             log.warn("订单:{},已结算,跳过重复关闭事件", orderInfo.getOrder_id());
             return;
         }
 
-        int rechargePayment;
-        int grantsPayment = 0;
-        var account = accountService.lambdaQuery().eq(Account::getUserId, washOrder.getUserId()).last("FOR UPDATE").one();
-        // 以实收金额扣款(优先扣减充值款,不够则扣除赠款)
-        int amountReceived = orderInfo.getAmount_received();
-        if (account.getRechargeBalance() >= amountReceived) {
-            rechargePayment = amountReceived;
-            accountService.lambdaUpdate()
-                    .setSql("balance = balance - {0}, recharge_balance = recharge_balance - {1}",
-                            amountReceived, rechargePayment)
-                    .eq(Account::getUserId, washOrder.getUserId())
-                    .update();
-        } else {
-            // 充值款余额不足以支付订单,则扣赠款
-            rechargePayment = account.getRechargeBalance();
-            grantsPayment = amountReceived - account.getRechargeBalance();
-            accountService.lambdaUpdate()
-                    .setSql("balance = balance - {0}, recharge_balance = 0, grants_balance = grants_balance - {1}",
-                            amountReceived, grantsPayment)
-                    .eq(Account::getUserId, washOrder.getUserId())
-                    .update();
-        }
-
-        washOrder
-                .setCloseType(orderInfo.getClose_type())
-                .setAmount(orderInfo.getAmount())
-                .setAmountReceivable(orderInfo.getAmount_receivable())
-                .setAmountReceived(amountReceived)
-                .setDiscountMoney(orderInfo.getDiscount_money())
-                .setDetail(orderInfo.getDetail())
-                .setEndTime(LocalDateTime.now())
-                .setTotalSeconds(Duration.between(washOrder.getStartTime(), LocalDateTime.now()).toSecondsPart())
-                .setRechargePayment(rechargePayment)
-                .setGrantsPayment(grantsPayment)
-                .setPayStatus(WashOrder.PAY_STATUS_已支付)
-                .setOrderStatus(WashOrder.ORDER_STATUS_成功);
-
-        washOrderService.updateById(washOrder);
-
-        // 订单金额为0的订单不处理
-        if (orderInfo.getAmount() == 0) {
-            return;
-        }
-
-        // 需要判断是否跨网点,分开处理;跨网点结算比例是消费站点分充值款实收的70%,充值站点保留30%
-        if (washOrder.getIsCross()) {
-            doCrossSplit(washOrder, KymCache.INSTANCE.getUserStationId(washOrder.getUserId()));
-        } else {
-            doLocalSplit(washOrder);
-        }
-
-        // 记录资金流水(含赠款明细)
-        var walletDetail = new WalletDetail();
-        walletDetail.setUserId(washOrder.getUserId());
-        walletDetail.setType(WalletDetail.TYPE_消费);
-        walletDetail.setOrderNo(washOrder.getOrderId());
-        walletDetail.setAmount(amountReceived);
-        walletDetail.setGrantsAmount(grantsPayment);
-        walletDetail.setBeforeBalance(account.getBalance());
-        walletDetail.setAfterBalance(account.getBalance() - amountReceived);
-        walletDetail.setBeforeGrantsBalance(account.getGrantsBalance());
-        walletDetail.setAfterGrantsBalance(account.getGrantsBalance() - grantsPayment);
-        walletDetail.setTransactionId(washOrder.getId().toString());
-        walletDetail.setTransactionTime(LocalDateTime.now());
-        walletDetail.setStatus(WalletDetail.STATUS_已确认);
-        walletDetailService.save(walletDetail);
-
-        // 处理充值权益优惠逻辑/优惠券优惠逻辑/账户优惠金额记录处理逻辑(涉及退款扣减优惠,在NoDiscountHandle中处理)
-        DiscountStrategyFactory.getDiscountStrategy(washOrder.getDiscountType()).computeDiscount(washOrder, account);
-
-        // 更新订单优惠金额
-        washOrderService.updateById(washOrder);
-
-        // 判断消费金额是否达标,达标发送领取停车优惠券消息
-        if (orderInfo.getAmount() >= WashOrder.PARKING_COUPON_MIN_AMOUNT) {
-            var parkingCouponUrl = KymCache.INSTANCE.getParkingQrCodeUrlByStationId(washOrder.getStationId());
-            // 二维码链接做转换防止重复使用,有效期2小时
-            var code = UUID.randomUUID().toString();
-            KymCache.INSTANCE.setParkingCouponCode(code, parkingCouponUrl, 3600*2L);
-            var url = DOMAIN + "/api/parking-coupon?code=" + code;
-            mpMsgTemplateService.sendParkingCouponMsg(washOrder, url);
-        } else {
-            // 发送公众号消息(订单完成)
-            mpMsgTemplateService.sendOrderCompletedMsg(washOrder, account.getBalance());
-        }
-    }
-
-    /**
-     * 本店消费分账 — V2 结算方案
-     * 同店消费不产生分账记录,资金不跨站,在周期结算时统一处理
-     *
-     * @param washOrder
-     */
-    @Transactional
-    protected void doLocalSplit(WashOrder washOrder) {
-        log.info("订单:{},本店消费,V2方案无需分账", washOrder.getOrderId());
-        // V2: 同店消费资金不出站,结算日统一计算
-    }
-
-    /**
-     * 跨店消费分账 — V2 结算方案
-     * 以订单实收总额(充值余额 + 赠款余额)的 70% 为跨店转账基数
-     * 平台服务费由结算日统一计算,70:30 拉新奖励比例在此体现
-     * 赠款为归属站点自行发起的营销成本,参与跨店分账,由归属站承担
-     *
-     * @param washOrder     洗车订单
-     * @param userStationId 用户归属站点(充值方)
-     */
-    @Transactional
-    protected void doCrossSplit(WashOrder washOrder, String userStationId) {
-        int rechargePayment = washOrder.getRechargePayment();
-        int grantsPayment = washOrder.getGrantsPayment();
-        int totalPayment = rechargePayment + grantsPayment;
-        if (totalPayment == 0) {
-            log.info("订单:{},支付金额为0,不产生跨店转账", washOrder.getOrderId());
-            return;
-        }
-        BigDecimal crossRate = new BigDecimal("0.7");
-        int crossAmount = crossRate.multiply(BigDecimal.valueOf(totalPayment)).setScale(0, RoundingMode.DOWN).intValue();
-        log.info("订单:{},执行跨店分账,归属站:{},消费站:{},充值款实收:{},赠款实收:{},转账金额:{}",
-                washOrder.getOrderId(), userStationId, washOrder.getStationId(), rechargePayment, grantsPayment, crossAmount);
-
-        // 归属站点跨店支出
-        var crossExpend = new SplitRecord()
-                .setFromStationId(userStationId)
-                .setToStationId(washOrder.getStationId())
-                .setTradeNo(washOrder.getOrderId())
-                .setAmount(crossAmount)
-                .setType(SplitRecord.TYPE_CROSS_EXPEND);
-
-        // 消费站点跨店收入
-        var crossIncome = new SplitRecord()
-                .setFromStationId(userStationId)
-                .setToStationId(washOrder.getStationId())
-                .setTradeNo(washOrder.getOrderId())
-                .setAmount(crossAmount)
-                .setType(SplitRecord.TYPE_CROSS_INCOME);
-
-        splitRecordService.saveBatch(List.of(crossExpend, crossIncome));
-        log.info("订单:{},跨店分账完成", washOrder.getOrderId());
+        orderSettlementService.settleOrder(washOrder, orderInfo);
     }
 }

+ 8 - 14
car-wash-service/src/main/java/com/kym/service/awoara/factory/AwoaraEventHandlerFactory.java

@@ -2,7 +2,9 @@ package com.kym.service.awoara.factory;
 
 import com.kym.common.exception.BusinessException;
 import com.kym.entity.awoara.Event;
-import com.kym.service.*;
+import com.kym.service.OrderSettlementService;
+import com.kym.service.WashDeviceService;
+import com.kym.service.WashOrderService;
 import com.kym.service.awoara.event.handle.*;
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.stereotype.Component;
@@ -19,24 +21,16 @@ public class AwoaraEventHandlerFactory {
 
     private static WashDeviceService washDeviceService;
     private static WashOrderService washOrderService;
-    private static WalletDetailService walletDetailService;
-    private static AccountService accountService;
+    private static OrderSettlementService orderSettlementService;
 
-    private static SplitRecordService splitRecordService;
 
-
-    private static MpMsgTemplateService mpMsgTemplateService;
-
-    public AwoaraEventHandlerFactory(WashDeviceService washDeviceService, WashOrderService washOrderService, WalletDetailService walletDetailService,
-                                     AccountService accountService, SplitRecordService splitRecordService, MpMsgTemplateService mpMsgTemplateService,
+    public AwoaraEventHandlerFactory(WashDeviceService washDeviceService, WashOrderService washOrderService,
+                                     OrderSettlementService orderSettlementService,
     @Value("${kym.domain}") String domain) {
         DOMAIN = domain;
         AwoaraEventHandlerFactory.washDeviceService = washDeviceService;
         AwoaraEventHandlerFactory.washOrderService = washOrderService;
-        AwoaraEventHandlerFactory.walletDetailService = walletDetailService;
-        AwoaraEventHandlerFactory.accountService = accountService;
-        AwoaraEventHandlerFactory.splitRecordService = splitRecordService;
-        AwoaraEventHandlerFactory.mpMsgTemplateService = mpMsgTemplateService;
+        AwoaraEventHandlerFactory.orderSettlementService = orderSettlementService;
     }
 
     public static AwoaraEventHandler getEventHandler(String eventName) {
@@ -48,7 +42,7 @@ public class AwoaraEventHandlerFactory {
                 case order_create -> new OrderCreateEventHandler(washOrderService);
                 case order_update -> new OrderUpdateEventHandler(washOrderService);
                 case order_close ->
-                        new OrderCloseEventHandler(washOrderService, walletDetailService, accountService, splitRecordService, mpMsgTemplateService,DOMAIN);
+                        new OrderCloseEventHandler(washOrderService, orderSettlementService);
                 case user_login -> new UserLoginEventHandler();
                 case card_event -> new CardEventHandler();
             };

+ 172 - 0
car-wash-service/src/main/java/com/kym/service/impl/OrderSettlementServiceImpl.java

@@ -0,0 +1,172 @@
+package com.kym.service.impl;
+
+import com.kym.entity.*;
+import com.kym.entity.awoara.OrderInfo;
+import com.kym.service.*;
+import com.kym.service.cache.KymCache;
+import com.kym.service.factory.DiscountStrategyFactory;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.time.Duration;
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.UUID;
+
+/**
+ * 订单结算服务实现
+ *
+ * @author skyline
+ */
+@Slf4j
+@Service
+public class OrderSettlementServiceImpl implements OrderSettlementService {
+
+    @Value("${kym.domain}")
+    private String DOMAIN;
+    private final WashOrderService washOrderService;
+    private final WalletDetailService walletDetailService;
+    private final AccountService accountService;
+    private final SplitRecordService splitRecordService;
+    private final MpMsgTemplateService mpMsgTemplateService;
+
+    public OrderSettlementServiceImpl(WashOrderService washOrderService, WalletDetailService walletDetailService,
+                                      AccountService accountService, SplitRecordService splitRecordService,
+                                      MpMsgTemplateService mpMsgTemplateService) {
+        this.washOrderService = washOrderService;
+        this.walletDetailService = walletDetailService;
+        this.accountService = accountService;
+        this.splitRecordService = splitRecordService;
+        this.mpMsgTemplateService = mpMsgTemplateService;
+    }
+
+    @Override
+    @Transactional
+    public void settleOrder(WashOrder washOrder, OrderInfo orderInfo) {
+        log.info("执行订单结算,订单:{},结算信息:{}", orderInfo.getOrder_id(), orderInfo);
+
+        // 幂等保护:重新加载订单并检查是否已结算
+        var freshOrder = washOrderService.lambdaQuery().eq(WashOrder::getId, washOrder.getId()).one();
+        if (freshOrder != null && Integer.valueOf(WashOrder.PAY_STATUS_已支付).equals(freshOrder.getPayStatus())) {
+            log.warn("订单:{},已结算,跳过重复结算", orderInfo.getOrder_id());
+            return;
+        }
+
+        int rechargePayment;
+        int grantsPayment = 0;
+        var account = accountService.lambdaQuery().eq(Account::getUserId, washOrder.getUserId()).last("FOR UPDATE").one();
+        int amountReceived = orderInfo.getAmount_received();
+        if (account.getRechargeBalance() >= amountReceived) {
+            rechargePayment = amountReceived;
+            accountService.lambdaUpdate()
+                    .setSql("balance = balance - {0}, recharge_balance = recharge_balance - {1}",
+                            amountReceived, rechargePayment)
+                    .eq(Account::getUserId, washOrder.getUserId())
+                    .update();
+        } else {
+            rechargePayment = account.getRechargeBalance();
+            grantsPayment = amountReceived - account.getRechargeBalance();
+            accountService.lambdaUpdate()
+                    .setSql("balance = balance - {0}, recharge_balance = 0, grants_balance = grants_balance - {1}",
+                            amountReceived, grantsPayment)
+                    .eq(Account::getUserId, washOrder.getUserId())
+                    .update();
+        }
+
+        washOrder
+                .setCloseType(orderInfo.getClose_type())
+                .setAmount(orderInfo.getAmount())
+                .setAmountReceivable(orderInfo.getAmount_receivable())
+                .setAmountReceived(amountReceived)
+                .setDiscountMoney(orderInfo.getDiscount_money())
+                .setDetail(orderInfo.getDetail())
+                .setEndTime(LocalDateTime.now())
+                .setTotalSeconds(Duration.between(washOrder.getStartTime(), LocalDateTime.now()).toSecondsPart())
+                .setRechargePayment(rechargePayment)
+                .setGrantsPayment(grantsPayment)
+                .setPayStatus(WashOrder.PAY_STATUS_已支付)
+                .setOrderStatus(WashOrder.ORDER_STATUS_成功);
+
+        washOrderService.updateById(washOrder);
+
+        if (orderInfo.getAmount() == 0) {
+            return;
+        }
+
+        if (washOrder.getIsCross()) {
+            doCrossSplit(washOrder, KymCache.INSTANCE.getUserStationId(washOrder.getUserId()));
+        } else {
+            doLocalSplit(washOrder);
+        }
+
+        var walletDetail = new WalletDetail();
+        walletDetail.setUserId(washOrder.getUserId());
+        walletDetail.setType(WalletDetail.TYPE_消费);
+        walletDetail.setOrderNo(washOrder.getOrderId());
+        walletDetail.setAmount(amountReceived);
+        walletDetail.setGrantsAmount(grantsPayment);
+        walletDetail.setBeforeBalance(account.getBalance());
+        walletDetail.setAfterBalance(account.getBalance() - amountReceived);
+        walletDetail.setBeforeGrantsBalance(account.getGrantsBalance());
+        walletDetail.setAfterGrantsBalance(account.getGrantsBalance() - grantsPayment);
+        walletDetail.setTransactionId(washOrder.getId().toString());
+        walletDetail.setTransactionTime(LocalDateTime.now());
+        walletDetail.setStatus(WalletDetail.STATUS_已确认);
+        walletDetailService.save(walletDetail);
+
+        DiscountStrategyFactory.getDiscountStrategy(washOrder.getDiscountType()).computeDiscount(washOrder, account);
+
+        washOrderService.updateById(washOrder);
+
+        if (orderInfo.getAmount() >= WashOrder.PARKING_COUPON_MIN_AMOUNT) {
+            var parkingCouponUrl = KymCache.INSTANCE.getParkingQrCodeUrlByStationId(washOrder.getStationId());
+            var code = UUID.randomUUID().toString();
+            KymCache.INSTANCE.setParkingCouponCode(code, parkingCouponUrl, 3600 * 2L);
+            var url = DOMAIN + "/api/parking-coupon?code=" + code;
+            mpMsgTemplateService.sendParkingCouponMsg(washOrder, url);
+        } else {
+            mpMsgTemplateService.sendOrderCompletedMsg(washOrder, account.getBalance());
+        }
+    }
+
+    @Transactional
+    protected void doLocalSplit(WashOrder washOrder) {
+        log.info("订单:{},本店消费,V2方案无需分账", washOrder.getOrderId());
+    }
+
+    @Transactional
+    protected void doCrossSplit(WashOrder washOrder, String userStationId) {
+        int rechargePayment = washOrder.getRechargePayment();
+        int grantsPayment = washOrder.getGrantsPayment();
+        int totalPayment = rechargePayment + grantsPayment;
+        if (totalPayment == 0) {
+            log.info("订单:{},支付金额为0,不产生跨店转账", washOrder.getOrderId());
+            return;
+        }
+        BigDecimal crossRate = new BigDecimal("0.7");
+        int crossAmount = crossRate.multiply(BigDecimal.valueOf(totalPayment)).setScale(0, RoundingMode.DOWN).intValue();
+        log.info("订单:{},执行跨店分账,归属站:{},消费站:{},充值款实收:{},赠款实收:{},转账金额:{}",
+                washOrder.getOrderId(), userStationId, washOrder.getStationId(), rechargePayment, grantsPayment, crossAmount);
+
+        var crossExpend = new SplitRecord()
+                .setFromStationId(userStationId)
+                .setToStationId(washOrder.getStationId())
+                .setTradeNo(washOrder.getOrderId())
+                .setAmount(crossAmount)
+                .setType(SplitRecord.TYPE_CROSS_EXPEND);
+
+        var crossIncome = new SplitRecord()
+                .setFromStationId(userStationId)
+                .setToStationId(washOrder.getStationId())
+                .setTradeNo(washOrder.getOrderId())
+                .setAmount(crossAmount)
+                .setType(SplitRecord.TYPE_CROSS_INCOME);
+
+        splitRecordService.saveBatch(List.of(crossExpend, crossIncome));
+        log.info("订单:{},跨店分账完成", washOrder.getOrderId());
+    }
+}

+ 87 - 6
car-wash-service/src/main/java/com/kym/service/impl/WashOrderServiceImpl.java

@@ -13,6 +13,7 @@ import com.kym.entity.Account;
 import com.kym.entity.User;
 import com.kym.entity.WashOrder;
 import com.kym.entity.WashStation;
+import com.kym.entity.awoara.OrderInfo;
 import com.kym.entity.common.PageBean;
 import com.kym.entity.common.PageParams;
 import com.kym.entity.queryParams.DeviceQueryParams;
@@ -22,12 +23,14 @@ import com.kym.entity.vo.StationTrendVo;
 import com.kym.entity.vo.WashOrderVo;
 import com.kym.mapper.WashOrderMapper;
 import com.kym.service.AccountService;
+import com.kym.service.OrderSettlementService;
 import com.kym.service.UserService;
 import com.kym.service.WashOrderService;
 import com.kym.service.WashStationService;
 import com.kym.service.awoara.AwoaraService;
 import com.kym.service.cache.KymCache;
 import com.kym.service.mybatisplus.MyBaseServiceImpl;
+import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.BeanUtils;
 import org.springframework.context.annotation.Lazy;
 import org.springframework.stereotype.Service;
@@ -48,6 +51,7 @@ import java.util.stream.Collectors;
  * @author skyline
  * @since 2024-09-11
  */
+@Slf4j
 @Service
 public class WashOrderServiceImpl extends MyBaseServiceImpl<WashOrderMapper, WashOrder> implements WashOrderService {
 
@@ -55,12 +59,16 @@ public class WashOrderServiceImpl extends MyBaseServiceImpl<WashOrderMapper, Was
     private final AccountService accountService;
     private final WashStationService washStationService;
     private final UserService userService;
+    private final OrderSettlementService orderSettlementService;
 
-    public WashOrderServiceImpl(AwoaraService awoaraService, AccountService accountService, @Lazy WashStationService washStationService, UserService userService) {
+    public WashOrderServiceImpl(AwoaraService awoaraService, AccountService accountService,
+                                @Lazy WashStationService washStationService, UserService userService,
+                                @Lazy OrderSettlementService orderSettlementService) {
         this.awoaraService = awoaraService;
         this.accountService = accountService;
         this.washStationService = washStationService;
         this.userService = userService;
+        this.orderSettlementService = orderSettlementService;
     }
 
     /**
@@ -89,6 +97,9 @@ public class WashOrderServiceImpl extends MyBaseServiceImpl<WashOrderMapper, Was
             throw new BusinessException("您有未完结的订单!");
         }
 
+        // 检查设备是否有僵死订单(设备显示忙碌但订单实际已超时)
+        checkAndResolveStaleOrder(params.getProductKey(), params.getDeviceName());
+
         var memberName = StpUtil.getSession().getString(User.ST_SESSION_KEY_MOBILE);
         var orderId = OrderUtils.getOrderNo();
 
@@ -140,12 +151,82 @@ public class WashOrderServiceImpl extends MyBaseServiceImpl<WashOrderMapper, Was
                 .eq(WashOrder::getOrderStatus, WashOrder.ORDER_STATUS_开机)
                 .eq(WashOrder::getPayStatus, WashOrder.PAY_STATUS_未支付)
                 .one();
-        if (order != null) {
-            if (order.getUserId() == StpUtil.getLoginIdAsLong()) {
-                awoaraService.closeOrder(params.getProductKey(), params.getDeviceName(), order.getOrderId());
-            } else {
-                throw new BusinessException("您没有权限关闭该订单!");
+        if (order == null) {
+            return;
+        }
+        if (order.getUserId() != StpUtil.getLoginIdAsLong()) {
+            throw new BusinessException("您没有权限关闭该订单!");
+        }
+
+        // 尝试发送关闭命令,失败后重试1次
+        boolean sent = sendCloseOrderWithRetry(params.getProductKey(), params.getDeviceName(), order.getOrderId(), 1);
+        if (sent) {
+            return;
+        }
+
+        // RRpc 失败,主动查询设备订单状态
+        log.warn("订单 {} 关闭命令发送失败,尝试主动查询设备状态", order.getOrderId());
+        try {
+            OrderInfo orderInfo = awoaraService.queryOrder(params.getProductKey(), params.getDeviceName(), order.getOrderId());
+            if (orderInfo != null && orderInfo.getClose_type() != null && !orderInfo.getClose_type().isEmpty()) {
+                log.info("订单 {} 设备已关闭,执行结算", order.getOrderId());
+                orderSettlementService.settleOrder(order, orderInfo);
+                return;
+            }
+            if (orderInfo != null && (orderInfo.getClose_type() == null || orderInfo.getClose_type().isEmpty())) {
+                // 设备还在运行,尝试强制关闭
+                log.info("订单 {} 设备仍在运行,尝试强制关闭", order.getOrderId());
+                try {
+                    awoaraService.forceCloseOrder(params.getProductKey(), params.getDeviceName());
+                } catch (Exception fe) {
+                    log.error("订单 {} 强制关闭也失败", order.getOrderId(), fe);
+                }
+            }
+        } catch (Exception e) {
+            log.error("订单 {} 主动查询设备状态也失败", order.getOrderId(), e);
+        }
+
+        throw new BusinessException("关闭订单失败,请稍后重试或联系管理员");
+    }
+
+    private boolean sendCloseOrderWithRetry(String productKey, String deviceName, String orderId, int maxRetries) {
+        for (int i = 0; i <= maxRetries; i++) {
+            try {
+                awoaraService.closeOrder(productKey, deviceName, orderId);
+                return true;
+            } catch (Exception e) {
+                if (i < maxRetries) {
+                    log.warn("订单 {} 关闭命令第{}次失败,准备重试", orderId, i + 1, e);
+                } else {
+                    log.error("订单 {} 关闭命令{}次均失败", orderId, maxRetries + 1, e);
+                }
+            }
+        }
+        return false;
+    }
+
+    /**
+     * 检查设备是否有僵死订单,有则尝试清理
+     */
+    private void checkAndResolveStaleOrder(String productKey, String deviceName) {
+        var staleOrder = lambdaQuery()
+                .eq(WashOrder::getProductKey, productKey)
+                .eq(WashOrder::getDeviceName, deviceName)
+                .eq(WashOrder::getOrderStatus, WashOrder.ORDER_STATUS_开机)
+                .eq(WashOrder::getPayStatus, WashOrder.PAY_STATUS_未支付)
+                .one();
+        if (staleOrder == null) {
+            return;
+        }
+        log.warn("设备 {}/{} 存在未完结订单 {},尝试查询设备实际状态", productKey, deviceName, staleOrder.getOrderId());
+        try {
+            OrderInfo orderInfo = awoaraService.queryOrder(productKey, deviceName, staleOrder.getOrderId());
+            if (orderInfo != null && orderInfo.getClose_type() != null && !orderInfo.getClose_type().isEmpty()) {
+                log.info("设备实际已关闭订单 {},执行结算", staleOrder.getOrderId());
+                orderSettlementService.settleOrder(staleOrder, orderInfo);
             }
+        } catch (Exception e) {
+            log.warn("查询设备订单状态失败,跳过清理:{}", e.getMessage());
         }
     }
 

+ 84 - 0
docs/stale-order-solution.md

@@ -0,0 +1,84 @@
+# 洗车订单僵死问题 — 三道防线方案
+
+## 问题描述
+
+用户点击「结算」后,服务器通过阿里云 IoT RRpc 向设备发送 `close_order` 命令。正常情况下,设备关机后会通过 AMQP 推送 `order_close` 事件,服务器收到后完成扣款和状态更新。
+
+**异常场景**:`order_close` 事件未到达平台时:
+- 订单一直处于「开机 + 未支付」状态
+- 设备一直处于「忙碌」状态
+- 其他用户无法使用该设备
+
+## 方案概览
+
+三道防线逐层递进,确保订单最终能被结算、设备最终能被释放:
+
+| 防线 | 触发时机 | 策略 | 
+|------|----------|------|
+| 防线一 | 用户点击结算时 | RRpc 失败后重试 + 主动查询设备回退 |
+| 防线二 | 定时任务(每5分钟) | 扫描超时订单,查询设备兜底结算 |
+| 防线三 | 用户启动设备时 | 检查设备是否有僵死订单,有则先清理 |
+
+## 防线一:closeOrder 增强
+
+**文件**: `WashOrderServiceImpl.closeOrder()`
+
+```
+用户点击结算
+  ├─ 发送 close_order RRpc
+  ├─ 失败 → 重试 1 次
+  └─ 重试仍失败 → queryOrder() 主动查询
+       ├─ 设备已关闭 → 直接结算
+       ├─ 设备仍在运行 → forceCloseOrder() 强制关闭
+       └─ 设备不可达 → 提示用户稍后重试
+```
+
+## 防线二:僵死订单定时对账
+
+**文件**: `StaleOrderReconciliationJob.java`  
+**模块**: car-wash-admin  
+**执行频率**: 每 5 分钟  
+**阈值配置**: `kym.stale-order-threshold-seconds`(默认 1800 秒 = 30 分钟)
+
+扫描条件:
+- `order_status = 0`(开机)
+- `pay_status = 0`(未支付)  
+- `start_time < now - threshold`
+
+处理策略:
+1. **查询设备订单状态** — 调用 `queryOrder()` RRpc
+   - 设备返回已关闭 → 用返回数据执行结算
+   - 设备返回仍在运行 → 跳过(正常进行中)
+2. **设备不可达,检查 DB 中设备状态**
+   - 设备已空闲 → 用最后一次 `order_update` 保存的数据兜底结算,`close_type` 标记为 `reconcile`
+3. **无法自动处理** → 标记 `stopReason` 为异常,等待人工介入
+
+## 防线三:启动前设备检查
+
+**文件**: `WashOrderServiceImpl.createOrder()` → `checkAndResolveStaleOrder()`
+
+在创建新订单前:
+1. 查询该设备是否有未完结订单
+2. 如有,调用 `queryOrder()` 确认设备实际状态
+3. 设备实际已关闭 → 先执行结算,再创建新订单
+
+## 新增/修改文件清单
+
+| 文件 | 操作 | 说明 |
+|------|------|------|
+| `car-wash-service/.../OrderSettlementService.java` | 新增 | 结算服务接口 |
+| `car-wash-service/.../OrderSettlementServiceImpl.java` | 新增 | 结算逻辑提取(从 OrderCloseEventHandler 抽取) |
+| `car-wash-service/.../OrderCloseEventHandler.java` | 修改 | 简化为委托 OrderSettlementService |
+| `car-wash-service/.../AwoaraEventHandlerFactory.java` | 修改 | 适配新构造参数 |
+| `car-wash-service/.../WashOrderServiceImpl.java` | 修改 | closeOrder 增强 + createOrder 预检 |
+| `car-wash-admin/.../StaleOrderReconciliationJob.java` | 新增 | 僵死订单定时对账任务 |
+
+## 配置项
+
+```yaml
+# application.yml(可选,默认 1800 秒 = 30 分钟)
+kym:
+  stale-order-threshold-seconds: 1800
+```
+
+建议设置为设备 `operationTimeout`(默认 3600 秒)的一半,既不会误伤正常订单,也能及时处理异常。