Przeglądaj źródła

fix: 修复支付分订单连续扫码扣费失败及商品记录写入优化

问题:
- 同一用户短时间内两次扫码,两笔支付分订单均未成功扣费

根因修复 (PayScoreServiceImpl):
1. isPayScoreOrderSafeToCancel 只看本地DB,微信侧已确认(DOING)的订单也会被取消
   → 增加 isPayScoreOrderConfirmedOnWechat() 查询微信侧状态
2. 同一 deviceId:userId 的 Redis preauth key 被第二次 pre-create 覆盖
   → storePreAuthToRedis 写入前检查 key 是否已存在
3. handlePayScoreCallback eventType 检查 PAYSCORE.USER_CONFIRMED 与微信实际
   发送的 PAYSCORE.USER_CONFIRM 不匹配,且 complete 失败后无重试
   → 修正 eventType + USER_CONFIRM 回调时若订单有金额未付则自动重试 complete
4. USER_CONFIRM 回调到达时本地订单可能尚未创建,回调状态丢失
   → 缓存早到的回调到 Redis,订单创建后通过 applyCachedCallback 恢复

优化 (HahaCallbackServiceImpl):
- saveOrderGoods: DELETE+INSERT 改为 code-based UPSERT
  ORDER 回调数据按商品编码匹配已有记录,匹配到则更新,未匹配到则新增,
  避免每单「先清除再重写」的冗余操作

Co-Authored-By: Claude <noreply@anthropic.com>
skyline 2 tygodni temu
rodzic
commit
d566f87def

+ 90 - 33
haha-service/src/main/java/com/haha/service/impl/HahaCallbackServiceImpl.java

@@ -1160,50 +1160,107 @@ public class HahaCallbackServiceImpl implements HahaCallbackService {
                 return;
             }
 
-            // 如果 ORC_RESULT 已写入占位商品记录,先删除再以回调数据重写
-            Long existingCount = orderGoodsMapper.selectCount(
+            // 收集 ORDER 回调中的商品编码
+            java.util.Set<String> callbackCodes = new java.util.HashSet<>();
+            for (int i = 0; i < goodsArray.size(); i++) {
+                String code = goodsArray.getJSONObject(i).getString("code");
+                if (code != null) {
+                    callbackCodes.add(code);
+                }
+            }
+
+            // 查询已有商品记录(可能来自 ORC_RESULT 的 sku_list 写入)
+            java.util.List<OrderGoods> existingGoods = orderGoodsMapper.selectList(
                     new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<OrderGoods>()
                             .eq(OrderGoods::getOrderId, orderId));
-            if (existingCount != null && existingCount > 0) {
-                log.info("订单已有 {} 条商品记录,先清除再以回调数据重写 - orderId: {}", existingCount, orderId);
-                orderGoodsMapper.delete(
-                        new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<OrderGoods>()
-                                .eq(OrderGoods::getOrderId, orderId));
+
+            // 按 code 建立索引,方便匹配
+            java.util.Map<String, OrderGoods> existingByCode = new java.util.LinkedHashMap<>();
+            if (existingGoods != null) {
+                for (OrderGoods eg : existingGoods) {
+                    if (eg.getCode() != null) {
+                        existingByCode.put(eg.getCode(), eg);
+                    }
+                }
             }
 
+            int updatedCount = 0;
+            int insertedCount = 0;
+
+            // 以 ORDER 回调数据为准:匹配到的更新,未匹配到的插入
             for (int i = 0; i < goodsArray.size(); i++) {
                 JSONObject goodsJson = goodsArray.getJSONObject(i);
-                OrderGoods goods = new OrderGoods();
-                goods.setOrderId(orderId);
-                goods.setOrderNo(orderNo);
-                goods.setActivityId(activityId);
-                goods.setDeviceId(deviceId);
-                goods.setUserId(userId);
-                goods.setCreateTime(LocalDateTime.now());
+                String code = goodsJson.getString("code");
+
+                OrderGoods existing = code != null ? existingByCode.remove(code) : null;
+
+                if (existing != null) {
+                    // 匹配到已有记录 → 更新(以回调的权威数据覆盖 ORC_RESULT 的预估数据)
+                    existing.setOrderNo(orderNo);
+                    existing.setProductId(goodsJson.getLong("id"));
+                    existing.setBarCode(goodsJson.getString("bar_code"));
+                    existing.setProductName(goodsJson.getString("product_name"));
+                    String picUrl = goodsJson.getString("pic");
+                    existing.setPic(normalizeImageUrl(picUrl));
+                    existing.setProductNum(goodsJson.getInteger("product_num"));
+                    if (goodsJson.get("money") != null) {
+                        existing.setMoney(new BigDecimal(goodsJson.getString("money")));
+                    }
+                    if (goodsJson.get("origin_price") != null) {
+                        existing.setOriginPrice(goodsJson.getBigDecimal("origin_price"));
+                    }
+                    if (goodsJson.get("price") != null) {
+                        existing.setPrice(goodsJson.getBigDecimal("price"));
+                    }
+                    orderGoodsMapper.updateById(existing);
+                    updatedCount++;
+                } else {
+                    // 无已有记录 → 新增
+                    OrderGoods goods = new OrderGoods();
+                    goods.setOrderId(orderId);
+                    goods.setOrderNo(orderNo);
+                    goods.setActivityId(activityId);
+                    goods.setDeviceId(deviceId);
+                    goods.setUserId(userId);
+                    goods.setCreateTime(LocalDateTime.now());
+
+                    goods.setProductId(goodsJson.getLong("id"));
+                    goods.setCode(code);
+                    goods.setBarCode(goodsJson.getString("bar_code"));
+                    goods.setProductName(goodsJson.getString("product_name"));
+                    String picUrl = goodsJson.getString("pic");
+                    goods.setPic(normalizeImageUrl(picUrl));
+                    goods.setProductNum(goodsJson.getInteger("product_num"));
+                    if (goodsJson.get("money") != null) {
+                        goods.setMoney(new BigDecimal(goodsJson.getString("money")));
+                    }
+                    if (goodsJson.get("origin_price") != null) {
+                        goods.setOriginPrice(goodsJson.getBigDecimal("origin_price"));
+                    }
+                    if (goodsJson.get("price") != null) {
+                        goods.setPrice(goodsJson.getBigDecimal("price"));
+                    }
 
-                goods.setProductId(goodsJson.getLong("id"));
-                goods.setCode(goodsJson.getString("code"));
-                goods.setBarCode(goodsJson.getString("bar_code"));
-                goods.setProductName(goodsJson.getString("product_name"));
-                // 处理图片链接:如果已经是完整 URL 则保持不变,否则添加域名前缀
-                String picUrl = goodsJson.getString("pic");
-                goods.setPic(normalizeImageUrl(picUrl));
-                goods.setProductNum(goodsJson.getInteger("product_num"));
-
-                if (goodsJson.get("money") != null) {
-                    goods.setMoney(new BigDecimal(goodsJson.getString("money")));
-                }
-                if (goodsJson.get("origin_price") != null) {
-                    goods.setOriginPrice(goodsJson.getBigDecimal("origin_price"));
-                }
-                if (goodsJson.get("price") != null) {
-                    goods.setPrice(goodsJson.getBigDecimal("price"));
+                    orderGoodsMapper.insert(goods);
+                    insertedCount++;
                 }
+            }
 
-                orderGoodsMapper.insert(goods);
+            // 清理 ORC_RESULT 有但 ORDER 回调没有的商品(通常不会发生,但保守处理)
+            if (!existingByCode.isEmpty()) {
+                for (OrderGoods stale : existingByCode.values()) {
+                    orderGoodsMapper.deleteById(stale.getId());
+                }
+                log.info("清理了 {} 条ORDER回调中不存在的商品记录 - orderId: {}", existingByCode.size(), orderId);
             }
 
-            log.info("订单商品保存成功 - orderId: {}, 商品数量: {}", orderId, goodsArray.size());
+            if (updatedCount > 0 && insertedCount > 0) {
+                log.info("订单商品保存成功 - orderId: {}, 更新{}条, 新增{}条", orderId, updatedCount, insertedCount);
+            } else if (updatedCount > 0) {
+                log.info("订单商品更新成功 - orderId: {}, 更新{}条 (已存在ORC_RESULT占位记录)", orderId, updatedCount);
+            } else {
+                log.info("订单商品保存成功 - orderId: {}, 商品数量: {}", orderId, goodsArray.size());
+            }
 
         } catch (Exception e) {
             log.error("保存订单商品失败 - orderId: {}", orderId, e);

+ 152 - 3
haha-service/src/main/java/com/haha/service/payment/payscore/impl/PayScoreServiceImpl.java

@@ -63,6 +63,8 @@ public class PayScoreServiceImpl implements PayScoreService {
     private static final long REDIS_TTL_MINUTES = 30;
     private static final String REDIS_TRACKING_KEY = "payscore:preauth:tracking:";
     private static final long REDIS_TRACKING_TTL_HOURS = 24;
+    private static final String REDIS_CALLBACK_CACHE_KEY = "payscore:callback:cache:";
+    private static final long REDIS_CALLBACK_CACHE_TTL_MINUTES = 10;
 
     private static final BigDecimal DEFAULT_RISK_FUND_AMOUNT = new BigDecimal("99.00");
     private static final String DEFAULT_RISK_FUND_NAME = "DEPOSIT";
@@ -307,11 +309,13 @@ public class PayScoreServiceImpl implements PayScoreService {
                 .one();
 
         if (order == null) {
-            log.error("[支付分服务] 未找到对应订单 - payScoreOrderId: {}", outOrderNo);
+            log.warn("[支付分服务] 订单尚未创建,缓存回调信息等待订单创建后应用 - payScoreOrderId: {}, eventType: {}",
+                    outOrderNo, eventType);
+            cacheCallbackForLater(outOrderNo, eventType, state);
             return result;
         }
 
-        if ("PAYSCORE.USER_CONFIRMED".equals(eventType)) {
+        if ("PAYSCORE.USER_CONFIRM".equals(eventType)) {
             order.setPayScoreState(PayScoreState.DOING.getCode());
             log.info("[支付分服务] 用户确认使用服务 - orderId: {}", order.getId());
         } else if ("PAYSCORE.USER_PAID".equals(eventType)) {
@@ -369,6 +373,21 @@ public class PayScoreServiceImpl implements PayScoreService {
             orderService.notifyHahaOrderStatusChanged(order.getOrderNo());
         }
 
+        // 用户确认后,如果订单已有金额且未支付,说明 ORDER 回调先到达但 complete 失败(或尚未到达),
+        // 此时支付分订单状态已变为 DOING(可完结),立即重试扣款
+        if ("PAYSCORE.USER_CONFIRM".equals(eventType)
+                && order.getTotalAmount() != null
+                && order.getTotalAmount().compareTo(BigDecimal.ZERO) > 0
+                && PayStatus.UNPAID.getCode().equals(order.getPayStatus())) {
+            log.info("[支付分服务] 用户已确认,重试扣款 - orderId: {}, amount: {}元",
+                    order.getId(), order.getTotalAmount());
+            try {
+                completePayScoreOrder(order.getId(), order.getTotalAmount(), null);
+            } catch (Exception e) {
+                log.error("[支付分服务] 重试扣款异常 - orderId: {}", order.getId(), e);
+            }
+        }
+
         return result;
     }
 
@@ -982,6 +1001,16 @@ public class PayScoreServiceImpl implements PayScoreService {
             return;
         }
         String redisKey = REDIS_KEY_PREFIX + deviceId + ":" + userId;
+
+        // 防止覆盖已有的未消费预授权:如果同一设备+用户已有 preauth key,不覆盖
+        // 新的 outOrderNo 已在 tracking SET 中,后续可通过三层恢复机制找到
+        String existing = stringRedisTemplate.opsForValue().get(redisKey);
+        if (existing != null && !existing.isEmpty()) {
+            log.warn("[支付分服务] Redis中已有未消费的预授权信息,不覆盖 - key: {}, 已有: {}, 新: {}",
+                    redisKey, existing, result.getOutOrderNo());
+            return;
+        }
+
         JSONObject json = new JSONObject();
         json.put("payScoreOrderId", result.getOutOrderNo());
         json.put("payScoreState", result.getState());
@@ -1010,12 +1039,16 @@ public class PayScoreServiceImpl implements PayScoreService {
             stringRedisTemplate.delete(redisKey);
             stringRedisTemplate.opsForSet().remove(REDIS_TRACKING_KEY + order.getUserId(), order.getPayScoreOrderId());
             log.debug("[支付分服务] 订单已有支付分信息,清理Redis - orderId: {}", order.getId());
+            // 检查是否有早到的微信回调缓存,应用后可能触发扣款
+            applyCachedCallback(order);
             return;
         }
 
         String jsonStr = stringRedisTemplate.opsForValue().get(redisKey);
         if (jsonStr == null) {
             log.warn("[支付分服务] Redis中未找到预授权信息 - key: {}", redisKey);
+            // 即使 Redis 没命中,也检查是否有缓存的回调(兜底恢复场景)
+            applyCachedCallback(order);
             return;
         }
 
@@ -1038,6 +1071,9 @@ public class PayScoreServiceImpl implements PayScoreService {
                 // 从追踪 SET 中移除,订单已正常关联不再需要清理
                 stringRedisTemplate.opsForSet().remove(REDIS_TRACKING_KEY + order.getUserId(), payScoreOrderId);
                 log.info("[支付分服务] 预授权信息已关联到订单 - orderId: {}, payScoreOrderId: {}", order.getId(), payScoreOrderId);
+
+                // 检查是否有早到的微信回调缓存,应用后可能触发扣款
+                applyCachedCallback(order);
             } else {
                 log.warn("[支付分服务] 预授权JSON中payScoreOrderId为null - key: {}, json: {}", redisKey, jsonStr);
             }
@@ -1363,7 +1399,11 @@ public class PayScoreServiceImpl implements PayScoreService {
                     .eq(Order::getPayScoreOrderId, outOrderNo)
                     .one();
             if (localOrder == null) {
-                // 没有关联的本地订单,可以安全取消(只是预创建的支付分记录)
+                // 没有关联的本地订单 — 但微信侧可能已被用户确认,需要查询微信状态
+                if (isPayScoreOrderConfirmedOnWechat(outOrderNo)) {
+                    log.warn("[支付分服务] 支付分订单在微信侧已确认,不可取消 - outOrderNo: {}", outOrderNo);
+                    return false;
+                }
                 return true;
             }
             String state = localOrder.getPayScoreState();
@@ -1381,4 +1421,113 @@ public class PayScoreServiceImpl implements PayScoreService {
             return false;
         }
     }
+
+    /**
+     * 查询微信侧支付分订单状态,判断用户是否已确认
+     * 微信侧状态为 DOING/DONE/USER_PAYING 表示用户已确认,不应取消
+     *
+     * @return true 微信侧已确认(不可取消),false 可以安全取消
+     */
+    private boolean isPayScoreOrderConfirmedOnWechat(String outOrderNo) {
+        if (payScoreStrategy == null) {
+            log.warn("[支付分服务] 支付分策略未初始化,保守认为已确认");
+            return true;
+        }
+        try {
+            PayScoreResult result = payScoreStrategy.queryServiceOrder(outOrderNo);
+            if (result.isSuccess()) {
+                String wxState = result.getState();
+                // CREATED 状态表示用户尚未确认,其他状态均表示已确认
+                boolean confirmed = wxState != null && !PayScoreState.CREATED.getCode().equals(wxState);
+                if (confirmed) {
+                    log.info("[支付分服务] 微信侧订单已确认 - outOrderNo: {}, wxState: {}", outOrderNo, wxState);
+                }
+                return confirmed;
+            }
+            // 查询失败时保守处理:认为已确认,不取消
+            log.warn("[支付分服务] 查询微信订单状态失败,保守不取消 - outOrderNo: {}, error: {}",
+                    outOrderNo, result.getErrorMsg());
+            return true;
+        } catch (Exception e) {
+            log.warn("[支付分服务] 查询微信订单状态异常,保守不取消 - outOrderNo: {}", outOrderNo, e);
+            return true;
+        }
+    }
+
+    /**
+     * 缓存早到的微信回调信息,等订单创建后再应用
+     */
+    private void cacheCallbackForLater(String outOrderNo, String eventType, String state) {
+        if (stringRedisTemplate == null) {
+            return;
+        }
+        try {
+            String key = REDIS_CALLBACK_CACHE_KEY + outOrderNo;
+            JSONObject json = new JSONObject();
+            json.put("eventType", eventType);
+            json.put("state", state);
+            stringRedisTemplate.opsForValue().set(key, json.toJSONString(),
+                    REDIS_CALLBACK_CACHE_TTL_MINUTES, TimeUnit.MINUTES);
+            log.info("[支付分服务] 回调信息已缓存 - payScoreOrderId: {}, eventType: {}, ttl: {}min",
+                    outOrderNo, eventType, REDIS_CALLBACK_CACHE_TTL_MINUTES);
+        } catch (Exception e) {
+            log.warn("[支付分服务] 缓存回调信息失败 - outOrderNo: {}", outOrderNo, e);
+        }
+    }
+
+    /**
+     * 应用缓存的微信回调信息到订单
+     * 在订单创建后调用,处理微信回调先于 ORC_RESULT 到达的场景
+     */
+    private void applyCachedCallback(Order order) {
+        if (stringRedisTemplate == null || order == null || order.getPayScoreOrderId() == null) {
+            return;
+        }
+        String key = REDIS_CALLBACK_CACHE_KEY + order.getPayScoreOrderId();
+        try {
+            String cached = stringRedisTemplate.opsForValue().get(key);
+            if (cached == null) {
+                return;
+            }
+            JSONObject json = JSON.parseObject(cached);
+            String eventType = json.getString("eventType");
+            String state = json.getString("state");
+
+            if ("PAYSCORE.USER_CONFIRM".equals(eventType)) {
+                // 用户已确认,更新订单状态为 DOING
+                order.setPayScoreState(PayScoreState.DOING.getCode());
+                order.setPayScoreFailReason(null);
+                orderService.updateById(order);
+                log.info("[支付分服务] 从缓存回调恢复用户确认状态 - orderId: {}, payScoreOrderId: {}",
+                        order.getId(), order.getPayScoreOrderId());
+
+                // 如果订单已有金额且未支付,立即触发扣款
+                if (order.getTotalAmount() != null
+                        && order.getTotalAmount().compareTo(BigDecimal.ZERO) > 0
+                        && PayStatus.UNPAID.getCode().equals(order.getPayStatus())) {
+                    log.info("[支付分服务] 缓存回调恢复后触发扣款 - orderId: {}, amount: {}元",
+                            order.getId(), order.getTotalAmount());
+                    try {
+                        completePayScoreOrder(order.getId(), order.getTotalAmount(), null);
+                    } catch (Exception e) {
+                        log.error("[支付分服务] 缓存回调恢复后扣款异常 - orderId: {}", order.getId(), e);
+                    }
+                }
+            } else {
+                log.info("[支付分服务] 缓存回调事件类型无需应用 - payScoreOrderId: {}, eventType: {}",
+                        order.getPayScoreOrderId(), eventType);
+            }
+
+            // 删除缓存,防止重复应用
+            stringRedisTemplate.delete(key);
+        } catch (Exception e) {
+            log.warn("[支付分服务] 应用缓存回调失败 - payScoreOrderId: {}, orderId: {}",
+                    order.getPayScoreOrderId(), order.getId(), e);
+            // 失败时也尝试删除缓存,避免反复失败
+            try {
+                stringRedisTemplate.delete(key);
+            } catch (Exception ignored) {
+            }
+        }
+    }
 }