Browse Source

feat: 支付分3笔限额分级清理 — 自动补扣+取消未确认+清理超时孤儿

策略(优先级从高到低):
1. 自动补扣:有金额的待支付订单(>1min) → 先收钱防损失
2. 取消未确认:CREATED状态 → 用户从未授权,安全取消
3. 清理超时孤儿:DOING/USER_PAYING+金额为空+>30min → 异常卡死单
4. Redis追踪清理:同设备旧预创建订单

清理后剩余>=3笔时拒绝开门,提示用户先处理未支付订单。

Co-Authored-By: Claude <noreply@anthropic.com>
skyline 3 days ago
parent
commit
bc9e8b7e6b

+ 86 - 15
haha-service/src/main/java/com/haha/service/payment/payscore/impl/PayScoreServiceImpl.java

@@ -852,8 +852,17 @@ public class PayScoreServiceImpl implements PayScoreService {
             return errorData;
         }
 
-        // 清理该用户旧的进行中支付分订单(DB中的 + 同设备Redis追踪的)
-        cancelStalePayScoreOrders(userId, deviceId);
+        // 清理该用户旧的进行中支付分订单(分级:自动补扣 > 取消未确认 > 清理超时孤儿 > Redis追踪)
+        int remainingActive = cancelStalePayScoreOrdersAndCount(userId, deviceId);
+
+        // 微信支付分限制:同一实名用户进行中订单不能超过 3 笔
+        if (remainingActive >= 3) {
+            log.warn("[支付分服务] 用户 {} 仍有 {} 笔未完结支付分订单,已达上限,拒绝创建新订单", userId, remainingActive);
+            Map<String, Object> limitData = new HashMap<>();
+            limitData.put("success", false);
+            limitData.put("errorMsg", "同一实名身份下进行中订单过多(已达" + remainingActive + "笔),请先完成或取消未支付订单后再试");
+            return limitData;
+        }
 
         // 如果没有传 openId,从用户表中获取
         if (openId == null || openId.isEmpty()) {
@@ -1104,11 +1113,46 @@ public class PayScoreServiceImpl implements PayScoreService {
     }
 
     /**
-     * 清理用户旧的进行中支付分订单
-     * 生产环境下回调正常运转后,此方法只在用户多次快速扫码(测试/异常)时触发
+     * 清理用户旧的进行中支付分订单,确保不超过微信支付分 3 笔限额。
+     *
+     * 分级策略(按优先级从高到低):
+     * 1. 自动补扣:有金额(>0) 且 创建超过 1 分钟的订单 → 直接扣款,不损失资金
+     * 2. 取消未确认:CREATED 状态(用户从未授权) → 安全取消
+     * 3. 取消超时孤儿:DOING/USER_PAYING + 金额为空 + 超过 30 分钟 → 异常单清理
+     * 4. Redis 追踪清理:同设备旧的预创建订单
+     *
+     * @return 清理后用户剩余的未完结支付分订单数量
      */
-    private void cancelStalePayScoreOrders(Long userId, String deviceId) {
-        // 1. 取消 CREATED 状态的孤儿订单(创建了但用户从未授权使用)
+    private int cancelStalePayScoreOrdersAndCount(Long userId, String deviceId) {
+        // 1. 自动补扣:有金额的待支付订单(>1min 避免与 ORDER 回调竞态)
+        try {
+            java.time.LocalDateTime autoCompleteThreshold = java.time.LocalDateTime.now().minusMinutes(1);
+            List<Order> readyOrders = orderService.lambdaQuery()
+                    .eq(Order::getUserId, userId)
+                    .eq(Order::getPayChannel, PaymentChannel.WECHAT_PAYSCORE.getCode())
+                    .in(Order::getPayScoreState, Arrays.asList(
+                            PayScoreState.DOING.getCode(),
+                            PayScoreState.USER_PAYING.getCode()))
+                    .isNotNull(Order::getPayScoreOrderId)
+                    .gt(Order::getTotalAmount, BigDecimal.ZERO)
+                    .le(Order::getCreateTime, autoCompleteThreshold)
+                    .list();
+
+            if (readyOrders != null && !readyOrders.isEmpty()) {
+                log.info("[支付分服务] 自动补扣用户 {} 的 {} 笔待支付订单", userId, readyOrders.size());
+                for (Order order : readyOrders) {
+                    try {
+                        orderService.completeOrderWithPayScore(order.getId(), order.getTotalAmount());
+                    } catch (Exception e) {
+                        log.error("[支付分服务] 自动补扣失败 - orderId: {}, orderNo: {}", order.getId(), order.getOrderNo(), e);
+                    }
+                }
+            }
+        } catch (Exception e) {
+            log.error("[支付分服务] 自动补扣查询失败 - userId: {}", userId, e);
+        }
+
+        // 2. 取消 CREATED 状态的孤儿订单(用户从未授权使用,不会损失资金)
         try {
             List<Order> inProgressOrders = orderService.lambdaQuery()
                     .eq(Order::getUserId, userId)
@@ -1124,12 +1168,11 @@ public class PayScoreServiceImpl implements PayScoreService {
                 }
             }
         } catch (Exception e) {
-            log.error("[支付分服务] 清理DB进行中订单失败 - userId: {}", userId, e);
+            log.error("[支付分服务] 清理CREATED订单失败 - userId: {}", userId, e);
         }
 
-        // 1.5 取消 DOING/USER_PAYING 状态但金额为空且超过30分钟的孤儿订单
-        // 仅清理因回调签名失败等异常造成的遗留订单,正常等待 ORDER 回调的订单不受影响
-        // 30分钟阈值确保覆盖 ORDER 回调的最大重试窗口,不会误取消正常进行中的订单
+        // 3. 取消超时孤儿:DOING/USER_PAYING + 金额为空 + 超过 30 分钟
+        //    仅清理回调签名失败等异常造成的遗留订单,正常等待 ORDER 回调的不受影响
         try {
             java.time.LocalDateTime staleThreshold = java.time.LocalDateTime.now().minusMinutes(30);
             List<Order> stuckOrders = orderService.lambdaQuery()
@@ -1139,22 +1182,21 @@ public class PayScoreServiceImpl implements PayScoreService {
                             PayScoreState.DOING.getCode(),
                             PayScoreState.USER_PAYING.getCode()))
                     .isNotNull(Order::getPayScoreOrderId)
-                    .and(w -> w.isNull(Order::getTotalAmount).or().eq(Order::getTotalAmount, BigDecimal.ZERO))
+                    .and(w -> w.isNull(Order::getTotalAmount).or().le(Order::getTotalAmount, BigDecimal.ZERO))
                     .le(Order::getCreateTime, staleThreshold)
                     .list();
 
             if (stuckOrders != null && !stuckOrders.isEmpty()) {
-                log.info("[支付分服务] 清理用户 {} 的 {} 笔已确认但金额为空超时订单(>30min)", userId, stuckOrders.size());
+                log.info("[支付分服务] 清理用户 {} 的 {} 笔超时孤儿订单(>30min)", userId, stuckOrders.size());
                 for (Order order : stuckOrders) {
                     cancelPayScoreOrderByOutOrderNo(order.getPayScoreOrderId(), order.getId());
                 }
             }
         } catch (Exception e) {
-            log.error("[支付分服务] 清理金额为空订单失败 - userId: {}", userId, e);
+            log.error("[支付分服务] 清理超时孤儿订单失败 - userId: {}", userId, e);
         }
 
-        // 2. 取消 Redis 追踪的预创建订单(之前未完成购物流程留下的)
-        // 必须检查关联的本地订单状态,避免误取消已确认/正在扣款的订单
+        // 4. 取消 Redis 追踪的预创建订单(之前未完成购物流程留下的)
         if (stringRedisTemplate != null) {
             try {
                 String trackingKey = REDIS_TRACKING_KEY + userId;
@@ -1176,6 +1218,35 @@ public class PayScoreServiceImpl implements PayScoreService {
                 log.error("[支付分服务] 清理Redis追踪订单失败 - userId: {}", userId, e);
             }
         }
+
+        // 统计清理后剩余的未完结订单数
+        return countActivePayScoreOrders(userId);
+    }
+
+    /**
+     * 统计用户当前未完结的支付分订单数量(DB 维度)
+     */
+    private int countActivePayScoreOrders(Long userId) {
+        try {
+            Long count = orderService.lambdaQuery()
+                    .eq(Order::getUserId, userId)
+                    .eq(Order::getPayChannel, PaymentChannel.WECHAT_PAYSCORE.getCode())
+                    .in(Order::getPayScoreState, Arrays.asList(
+                            PayScoreState.CREATED.getCode(),
+                            PayScoreState.DOING.getCode(),
+                            PayScoreState.USER_PAYING.getCode()))
+                    .isNotNull(Order::getPayScoreOrderId)
+                    .count();
+            return count != null ? count.intValue() : 0;
+        } catch (Exception e) {
+            log.warn("[支付分服务] 统计未完结订单失败 - userId: {}", userId, e);
+            return 99; // 保守值,宁可拒绝也不要超标
+        }
+    }
+
+    /** @deprecated 由 cancelStalePayScoreOrdersAndCount 替代 */
+    private void cancelStalePayScoreOrders(Long userId, String deviceId) {
+        cancelStalePayScoreOrdersAndCount(userId, deviceId);
     }
 
     private void cancelPayScoreOrderByOutOrderNo(String outOrderNo, Long localOrderId) {