Browse Source

feat: 支付分订单清理链路全链路优化

P0 - nobuy 场景取消支付分:
- PayScoreServiceImpl 新增 cancelPayScoreForNoConsume() 公开方法
- HahaCallbackServiceImpl.handleNoConsume 中调用取消微信支付分+清理Redis

P0 - 移除跨设备 tracking SET 清理:
- cancelStalePayScoreOrdersAndCount 移除 Step4 跨设备清理
- tracking SET 清理改由正常流程/nobuy/cancelOldDeviceOrders/TTL 各自负责

P1 - scan-open 传入 payScoreOrderId:
- ScanOpenDTO 新增 payScoreOrderId 字段
- 前端传参直接持久化到 DoorRecord,不依赖 Redis
- 无参数时回退 Redis 兜底

P2 - 定时任务兜底 USER_PAYING 补扣:
- PayScoreSyncTask.processSyncOrders 增加重试逻辑
- USER_PAYING + totalAmount>0 + UNPAID → 重试 completePayScoreOrder

P2 - ORDER 回调 DoorRecord 恢复:
- processPayScorePayment 新增 tryRecoverFromDoorRecord
- tracking SET 未命中时从 DoorRecord 恢复 payScoreOrderId

Co-Authored-By: Claude <noreply@anthropic.com>
skyline 2 tuần trước cách đây
mục cha
commit
566ae18927

+ 22 - 2
haha-admin/src/main/java/com/haha/admin/task/PayScoreSyncTask.java

@@ -1,6 +1,7 @@
 package com.haha.admin.task;
 
 import com.haha.common.enums.PayScoreState;
+import com.haha.common.enums.PayStatus;
 import com.haha.common.utils.TraceIdUtils;
 import com.haha.entity.Order;
 import com.haha.service.OrderService;
@@ -11,6 +12,7 @@ import lombok.extern.slf4j.Slf4j;
 import org.springframework.scheduling.annotation.Scheduled;
 import org.springframework.stereotype.Component;
 
+import java.math.BigDecimal;
 import java.time.LocalDateTime;
 import java.time.temporal.ChronoUnit;
 import java.util.List;
@@ -123,9 +125,11 @@ public class PayScoreSyncTask {
     private void processSyncOrders(List<Order> orders) {
         int syncedCount = 0;
         int stillPayingCount = 0;
+        int retriedCount = 0;
 
         for (Order order : orders) {
             try {
+                String oldState = order.getPayScoreState();
                 PayScoreResult result = payScoreService.syncPayScoreStatus(order.getId());
                 if (result != null && result.isSuccess()) {
                     syncedCount++;
@@ -138,6 +142,22 @@ public class PayScoreSyncTask {
                     if (PayScoreState.isPaying(state)) {
                         stillPayingCount++;
                         checkOverdueWarning(updatedOrder);
+
+                        // 如果订单金额 > 0 且未支付,说明之前 complete 失败,重试扣款
+                        if (updatedOrder.getTotalAmount() != null
+                                && updatedOrder.getTotalAmount().compareTo(BigDecimal.ZERO) > 0
+                                && PayStatus.UNPAID.getCode().equals(updatedOrder.getPayStatus())) {
+                            log.info("[支付分同步] 重试扣款 - orderId: {}, amount: {}元, failReason: {}",
+                                    updatedOrder.getId(), updatedOrder.getTotalAmount(),
+                                    updatedOrder.getPayScoreFailReason());
+                            try {
+                                payScoreService.completePayScoreOrder(
+                                        updatedOrder.getId(), updatedOrder.getTotalAmount(), null);
+                                retriedCount++;
+                            } catch (Exception e) {
+                                log.error("[支付分同步] 重试扣款异常 - orderId: {}", updatedOrder.getId(), e);
+                            }
+                        }
                     } else if (PayScoreState.isExpired(state)) {
                         log.info("[支付分同步] 订单已过期 - orderId: {}", updatedOrder.getId());
                     }
@@ -147,8 +167,8 @@ public class PayScoreSyncTask {
             }
         }
 
-        log.info("[支付分同步] 同步完成 - 共处理 {} 单, 状态变更 {} 单, 仍待支付 {} 单",
-                orders.size(), syncedCount, stillPayingCount);
+        log.info("[支付分同步] 同步完成 - 共处理 {} 单, 状态变更 {} 单, 仍待支付 {} 单, 重试扣款 {} 单",
+                orders.size(), syncedCount, stillPayingCount, retriedCount);
     }
 
     /**

+ 6 - 0
haha-entity/src/main/java/com/haha/entity/dto/ScanOpenDTO.java

@@ -12,4 +12,10 @@ public class ScanOpenDTO {
      * 设备ID(SN号)
      */
     private String deviceId;
+
+    /**
+     * 微信支付分预授权订单号(outOrderNo),由 pre-create 接口返回
+     * 前端传入后直接持久化到 DoorRecord,不依赖 Redis
+     */
+    private String payScoreOrderId;
 }

+ 1 - 1
haha-miniapp/src/main/java/com/haha/miniapp/controller/DeviceController.java

@@ -47,7 +47,7 @@ public class DeviceController {
             log.info("用户 {} 扫码请求打开设备 {}", userId, deviceId);
 
             // 3. 调用Service层处理业务逻辑
-            OpenDoorVO result = deviceService.scanOpenDoor(deviceId, userId);
+            OpenDoorVO result = deviceService.scanOpenDoor(deviceId, userId, dto.getPayScoreOrderId());
             return Result.success("开门成功,请取货", result);
             
         } catch (cn.dev33.satoken.exception.NotLoginException e) {

+ 2 - 1
haha-service/src/main/java/com/haha/service/DeviceService.java

@@ -77,10 +77,11 @@ public interface DeviceService extends IService<Device> {
      * 
      * @param deviceId 设备ID
      * @param userId 用户ID
+     * @param payScoreOrderId 微信支付分预授权订单号(前端pre-create后传入,可选)
      * @return 开门结果
      * @throws HahaException 开门失败时抛出
      */
-    OpenDoorVO scanOpenDoor(String deviceId, Long userId) throws HahaException;
+    OpenDoorVO scanOpenDoor(String deviceId, Long userId, String payScoreOrderId) throws HahaException;
 
     /**
      * 将设备关联到门店

+ 13 - 5
haha-service/src/main/java/com/haha/service/impl/DeviceServiceImpl.java

@@ -235,7 +235,7 @@ private final HahaClient hahaClient;
     }
 
     @Override
-    public OpenDoorVO scanOpenDoor(String deviceId, Long userId) throws HahaException {
+    public OpenDoorVO scanOpenDoor(String deviceId, Long userId, String payScoreOrderId) throws HahaException {
         log.info("用户 {} 请求打开设备 {}", userId, deviceId);
 
         // 1. 检查设备在线状态
@@ -260,8 +260,8 @@ private final HahaClient hahaClient;
         String source = "MINIAPP";
 
         OpenDoorResult openResult = hahaClient.getDeviceApi().openDoor(deviceId, String.valueOf(userId), openType, doorIndex, source);
-        
-        // 4. 【修改】保存开门记录到数据库和 Redis(不再立即创建订单)
+
+        // 4. 保存开门记录到数据库和 Redis
         doorRecordService.saveDoorOpenRecord(
             deviceId,
             userId,
@@ -271,8 +271,16 @@ private final HahaClient hahaClient;
             source
         );
 
-        // 4.1 将支付分预授权信息持久化到 DoorRecord,防止 Redis 过期后丢失
-        persistPayScorePreAuth(deviceId, userId, openResult.getActivityId());
+        // 4.1 持久化支付分预授权信息到 DoorRecord
+        // 优先使用前端传入的 payScoreOrderId(精确匹配当次 pre-create),否则从 Redis 兜底
+        if (payScoreOrderId != null && !payScoreOrderId.isEmpty()) {
+            String serviceId = payScoreService != null ? payScoreService.getServiceId() : null;
+            doorRecordService.updatePayScorePreAuth(openResult.getActivityId(), payScoreOrderId, serviceId);
+            log.info("支付分预授权信息已从请求参数持久化到 DoorRecord - activityId: {}, payScoreOrderId: {}",
+                    openResult.getActivityId(), payScoreOrderId);
+        } else {
+            persistPayScorePreAuth(deviceId, userId, openResult.getActivityId());
+        }
 
         // 5. 返回成功结果
         log.info("开门成功 - 设备:{}, 活动 ID: {}, 用户 ID: {}", deviceId, openResult.getActivityId(), openResult.getUserId());

+ 68 - 4
haha-service/src/main/java/com/haha/service/impl/HahaCallbackServiceImpl.java

@@ -401,9 +401,23 @@ public class HahaCallbackServiceImpl implements HahaCallbackService {
 
     /**
      * 处理无消费场景
+     * 用户开门但未取货:标记 DoorRecord、关门、取消关联的支付分订单、清理 Redis
      */
     private void handleNoConsume(String activityId) {
         log.info("用户打开柜门但未消费,无需创建订单 - activityId: {}", activityId);
+
+        // 取消关联的支付分订单(用户在微信侧已确认但未取货)
+        try {
+            DoorRecord record = doorRecordService.getByActivityId(activityId);
+            if (record != null && record.getPayScoreOrderId() != null && payScoreService != null) {
+                ((com.haha.service.payment.payscore.impl.PayScoreServiceImpl) payScoreService)
+                        .cancelPayScoreForNoConsume(record.getPayScoreOrderId(),
+                                record.getUserId(), record.getDeviceId());
+            }
+        } catch (Exception e) {
+            log.error("无消费场景取消支付分订单失败 - activityId: {}", activityId, e);
+        }
+
         doorRecordService.markNoConsume(activityId);
         doorRecordService.updateDoorClosed(activityId);
     }
@@ -1803,14 +1817,20 @@ public class HahaCallbackServiceImpl implements HahaCallbackService {
                     log.info("支付分订单ID已从追踪SET恢复 - orderId: {}, payScoreOrderId: {}",
                             order.getId(), recovered);
                 } else {
+                    // tracking SET 未命中,尝试从 DoorRecord 恢复(scan-open 时持久化的)
+                    if (!tryRecoverFromDoorRecord(order)) {
+                        log.debug("订单未使用支付分,跳过扣费 - orderId: {}, payChannel: {}",
+                                order.getId(), order.getPayChannel());
+                        return;
+                    }
+                }
+            } else {
+                // payScoreService 未初始化,尝试从 DoorRecord 恢复
+                if (!tryRecoverFromDoorRecord(order)) {
                     log.debug("订单未使用支付分,跳过扣费 - orderId: {}, payChannel: {}",
                             order.getId(), order.getPayChannel());
                     return;
                 }
-            } else {
-                log.debug("订单未使用支付分,跳过扣费 - orderId: {}, payChannel: {}",
-                        order.getId(), order.getPayChannel());
-                return;
             }
         }
 
@@ -1879,6 +1899,50 @@ public class HahaCallbackServiceImpl implements HahaCallbackService {
         }
     }
 
+    /**
+     * 从 DoorRecord 恢复支付分订单ID(多层恢复的最后一道防线)
+     * 当 Redis preauth key 过期 + tracking SET 也被清理时,DoorRecord 上仍有 scan-open 时持久化的信息
+     *
+     * @return true 恢复成功并已回写 DB,false 未找到
+     */
+    private boolean tryRecoverFromDoorRecord(Order order) {
+        if (order.getActivityId() == null) {
+            return false;
+        }
+        try {
+            DoorRecord record = doorRecordService.getByActivityId(order.getActivityId());
+            if (record != null && record.getPayScoreOrderId() != null) {
+                String recovered = record.getPayScoreOrderId();
+                String svcId = record.getServiceId();
+
+                order.setPayScoreOrderId(recovered);
+                order.setPayChannel(PaymentChannel.WECHAT_PAYSCORE.getCode());
+                order.setPayType("微信支付分");
+                order.setPayScoreState(PayScoreState.CREATED.getCode());
+                if (svcId != null) {
+                    order.setServiceId(svcId);
+                }
+
+                // 回写到数据库
+                orderService.lambdaUpdate()
+                        .set(Order::getPayScoreOrderId, recovered)
+                        .set(Order::getPayChannel, PaymentChannel.WECHAT_PAYSCORE.getCode())
+                        .set(Order::getPayType, "微信支付分")
+                        .set(Order::getPayScoreState, PayScoreState.CREATED.getCode())
+                        .set(svcId != null, Order::getServiceId, svcId)
+                        .eq(Order::getId, order.getId())
+                        .update();
+                log.info("支付分订单ID已从DoorRecord恢复 - orderId: {}, payScoreOrderId: {}, activityId: {}",
+                        order.getId(), recovered, order.getActivityId());
+                return true;
+            }
+        } catch (Exception e) {
+            log.warn("从DoorRecord恢复支付分订单ID失败 - orderId: {}, activityId: {}",
+                    order.getId(), order.getActivityId(), e);
+        }
+        return false;
+    }
+
     /**
      * 同步支付状态到哈哈平台
      * 告知哈哈平台该订单已完成支付

+ 34 - 25
haha-service/src/main/java/com/haha/service/payment/payscore/impl/PayScoreServiceImpl.java

@@ -1173,7 +1173,12 @@ public class PayScoreServiceImpl implements PayScoreService {
      * 1. 自动补扣:有金额(>0) 且 创建超过 1 分钟的订单 → 直接扣款,不损失资金
      * 2. 取消未确认:CREATED 状态(用户从未授权) → 安全取消
      * 3. 取消超时孤儿:DOING/USER_PAYING + 金额为空 + 超过 30 分钟 → 异常单清理
-     * 4. Redis 追踪清理:同设备旧的预创建订单
+     *
+     * 注意:tracking SET 的清理已移至以下独立流程:
+     *   - 正常消费:applyPreAuthPayScore 创建订单时自动移除
+     *   - 无消费:handleNoConsume → cancelPayScoreForNoConsume 取消+移除
+     *   - 同设备旧订单:cancelOldDeviceOrders 在 preCreate 成功后清理
+     *   - 过期兜底:Redis tracking SET 24h TTL 自动过期
      *
      * @return 清理后用户剩余的未完结支付分订单数量
      */
@@ -1250,29 +1255,6 @@ public class PayScoreServiceImpl implements PayScoreService {
             log.error("[支付分服务] 清理超时孤儿订单失败 - userId: {}", userId, e);
         }
 
-        // 4. 取消 Redis 追踪的预创建订单(之前未完成购物流程留下的)
-        if (stringRedisTemplate != null) {
-            try {
-                String trackingKey = REDIS_TRACKING_KEY + userId;
-                java.util.Set<String> trackedOrders = stringRedisTemplate.opsForSet().members(trackingKey);
-                if (trackedOrders != null && !trackedOrders.isEmpty()) {
-                    log.info("[支付分服务] 清理用户 {} 的 {} 笔追踪预创建订单", userId, trackedOrders.size());
-                    for (String outOrderNo : trackedOrders) {
-                        if (outOrderNo != null && !outOrderNo.isEmpty()) {
-                            if (!isPayScoreOrderSafeToCancel(outOrderNo)) {
-                                log.info("[支付分服务] 跳过取消已确认的支付分订单 - outOrderNo: {}", outOrderNo);
-                                continue;
-                            }
-                            cancelPayScoreOrderByOutOrderNo(outOrderNo, null);
-                            stringRedisTemplate.opsForSet().remove(trackingKey, outOrderNo);
-                        }
-                    }
-                }
-            } catch (Exception e) {
-                log.error("[支付分服务] 清理Redis追踪订单失败 - userId: {}", userId, e);
-            }
-        }
-
         // 统计清理后剩余的未完结订单数
         return countActivePayScoreOrders(userId);
     }
@@ -1303,11 +1285,38 @@ public class PayScoreServiceImpl implements PayScoreService {
         cancelStalePayScoreOrdersAndCount(userId, deviceId);
     }
 
+    /**
+     * 无消费场景取消支付分订单:用户开门但未取货,安全取消并清理 Redis
+     * 由 HahaCallbackServiceImpl.handleNoConsume 调用
+     */
+    public void cancelPayScoreForNoConsume(String outOrderNo, Long userId, String deviceId) {
+        if (outOrderNo == null) {
+            return;
+        }
+        log.info("[支付分服务] 无消费取消支付分订单 - outOrderNo: {}, userId: {}, deviceId: {}", outOrderNo, userId, deviceId);
+
+        // 取消微信侧支付分订单
+        cancelPayScoreOrderByOutOrderNo(outOrderNo, null, "用户未取货,自动取消");
+
+        // 清理 Redis preauth key
+        if (stringRedisTemplate != null) {
+            String redisKey = REDIS_KEY_PREFIX + deviceId + ":" + userId;
+            stringRedisTemplate.delete(redisKey);
+            // 从追踪 SET 移除
+            stringRedisTemplate.opsForSet().remove(REDIS_TRACKING_KEY + userId, outOrderNo);
+            log.debug("[支付分服务] 无消费订单Redis已清理 - key: {}, outOrderNo: {}", redisKey, outOrderNo);
+        }
+    }
+
     private void cancelPayScoreOrderByOutOrderNo(String outOrderNo, Long localOrderId) {
+        cancelPayScoreOrderByOutOrderNo(outOrderNo, localOrderId, "新订单创建前自动取消旧订单");
+    }
+
+    private void cancelPayScoreOrderByOutOrderNo(String outOrderNo, Long localOrderId, String reason) {
         try {
             PayScoreCancelRequest cancelReq = new PayScoreCancelRequest();
             cancelReq.setOutOrderNo(outOrderNo);
-            cancelReq.setReason("新订单创建前自动取消旧订单");
+            cancelReq.setReason(reason);
             PayScoreResult cancelResult = payScoreStrategy.cancelServiceOrder(cancelReq);
 
             if (cancelResult.isSuccess()) {