ソースを参照

优化订单信用分支付失败场景处理

skyline 3 週間 前
コミット
ac37ca390d

+ 43 - 7
docs/订单与支付模块.md

@@ -29,7 +29,11 @@
 | refundTime | LocalDateTime | 退款时间 |
 | refundReason | String | 退款原因 |
 | payScoreOrderId | String | 支付分服务订单号(微信侧) |
-| payScoreState | String | 支付分状态:CREATED / DOING / USER_PAYING / DONE |
+| payScoreState | String | 支付分状态:CREATED / DOING / USER_PAYING / DONE / REVOKED |
+| payScoreFailReason | String | 支付分扣款失败原因(记录微信 API 返回的错误信息) |
+| serviceId | String | 支付分服务ID |
+| serviceStartTime | LocalDateTime | 服务开始时间 |
+| serviceEndTime | LocalDateTime | 服务结束时间 |
 | status | Integer | 订单状态:0-已取消 1-待支付 2-已完成 3-已关闭 |
 | orderType | String | 订单类型:OUT-正常消费,IN-异物放入 |
 | items | String | 商品识别信息JSON |
@@ -225,14 +229,42 @@ PaymentService (门面)
 completeUserPayScoreOrder() → 完结支付分(自动扣款)
-支付分回调 → 更新订单 payStatus = PAID
+  扣款成功(DONE) ──→ 微信回调 PAYSCORE.USER_PAID ──→ 订单完成
+  扣款失败(余额不足等) ──→ 本地标记 USER_PAYING,记录 failReason
+    ↓                        ↓
+  微信支付分自动推送待支付提醒     PayScoreSyncTask 每 5 分钟同步状态
+    ↓                        ↓
+  用户完成支付 → 微信回调 → DONE → 订单完成
 ```
 
-**支付分服务订单状态**:
-- CREATED:已创建
-- DOING:服务中(用户正在购物)
-- USER_PAYING:用户支付中
+**支付分服务订单状态(PayScoreState 枚举)**:
+- CREATED:已创建(用户扫码时创建服务订单)
+- DOING:进行中(用户确认使用服务,正在购物)
+- USER_PAYING:用户支付中(扣款已发起但用户尚未完成支付,如银行卡余额不足)
 - DONE:已完成(扣款成功)
+- REVOKED:已取消(用户或商户取消服务订单)
+
+#### 扣款失败的 USER_PAYING 处理
+
+当完结支付分订单时,若用户微信绑定的银行卡余额不足,微信 API 返回状态为 `USER_PAYING`,此时:
+
+1. **服务端标记**:`payScoreState` 设为 `USER_PAYING`,`payScoreFailReason` 记录失败原因
+2. **微信端自动催收**:微信支付分平台会自动向用户推送"待支付提醒"通知,无需商户额外开发
+3. **定时状态同步**:`PayScoreSyncTask` 每 5 分钟查询 `USER_PAYING` 订单并调用微信 `queryServiceOrder` 同步最新状态
+4. **超时预警**:超过 24 小时仍未支付则记录 warn 日志,供运营关注处理
+
+```
+扣款失败(USER_PAYING)
+    ↓
+本地: payScoreState=USER_PAYING, payScoreFailReason=记录原因
+    ↓
+微信端自动推送待支付提醒(微信原生能力)
+    ↓
+PayScoreSyncTask 每 5 分钟同步
+    ↓
+用户支付成功 → 微信回调 PAYSCORE.USER_PAID → DONE → 订单完成
+用户超时未付 → 24h 后 warn 日志 → 运营介入
+```
 
 ### 4.4 退款流程
 
@@ -264,7 +296,9 @@ OrderServiceImpl.refund()
 | `haha-service/.../HahaCallbackServiceImpl.java` | 设备回调处理(订单回调、优惠券扣减) |
 | `haha-service/.../payment/PaymentService.java` | 支付门面接口 |
 | `haha-service/.../payment/impl/PaymentServiceImpl.java` | 支付门面实现 |
-| `haha-service/.../payment/payscore/PayScoreService.java` | 支付分服务接口 |
+| `haha-service/.../payment/payscore/PayScoreServiceImpl.java` | 支付分服务实现(含扣款失败 USER_PAYING 处理) |
+| `haha-service/.../payment/payscore/impl/WxPayScoreStrategy.java` | 微信支付分 API 策略实现 |
+| `haha-admin/.../task/PayScoreSyncTask.java` | 支付分 USER_PAYING 状态定时同步任务 |
 | `haha-admin/.../OrderController.java` | 运营平台订单API |
 | `haha-miniapp/.../OrderController.java` | 小程序订单API |
 | `haha-miniapp/.../PaymentController.java` | 小程序支付API |
@@ -273,3 +307,5 @@ OrderServiceImpl.refund()
 | `haha-common/.../enums/OrderStatus.java` | 订单状态枚举 |
 | `haha-common/.../enums/PayStatus.java` | 支付状态枚举 |
 | `haha-common/.../enums/PaymentChannel.java` | 支付渠道枚举 |
+| `haha-common/.../enums/PayScoreState.java` | 支付分状态枚举 |
+| `docs/database/add_payscore_fail_reason.sql` | 支付分扣款失败原因字段迁移 SQL |

+ 102 - 0
haha-admin/src/main/java/com/haha/admin/task/PayScoreSyncTask.java

@@ -0,0 +1,102 @@
+package com.haha.admin.task;
+
+import com.haha.common.enums.PayScoreState;
+import com.haha.common.utils.TraceIdUtils;
+import com.haha.entity.Order;
+import com.haha.service.OrderService;
+import com.haha.service.payment.payscore.PayScoreService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import java.time.LocalDateTime;
+import java.time.temporal.ChronoUnit;
+import java.util.List;
+
+/**
+ * 支付分状态同步定时任务
+ * 定期查询处于 USER_PAYING 状态的订单,主动从微信侧同步最新支付状态
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class PayScoreSyncTask {
+
+    private final OrderService orderService;
+    private final PayScoreService payScoreService;
+
+    /**
+     * 支付分状态同步阈值:服务结束后 30 分钟才开始同步
+     */
+    private static final int SYNC_DELAY_MINUTES = 30;
+
+    /**
+     * 超时警告阈值:处于 USER_PAYING 状态超过 24 小时
+     */
+    private static final int OVERDUE_HOURS = 24;
+
+    /**
+     * 每 5 分钟执行一次,同步处于 USER_PAYING 状态的支付分订单
+     */
+    @Scheduled(cron = "0 */5 * * * ?")
+    public void syncPayingOrders() {
+        String traceId = TraceIdUtils.generateTraceId();
+        TraceIdUtils.setTraceId(traceId);
+
+        try {
+            log.info("[支付分同步] 开始同步 USER_PAYING 状态订单");
+
+            // 查询 payScoreState = USER_PAYING 且 serviceEndTime 超过 SYNC_DELAY_MINUTES 分钟的订单
+            List<Order> payingOrders = orderService.lambdaQuery()
+                    .eq(Order::getPayScoreState, PayScoreState.USER_PAYING.getCode())
+                    .lt(Order::getServiceEndTime, LocalDateTime.now().minusMinutes(SYNC_DELAY_MINUTES))
+                    .list();
+
+            if (payingOrders.isEmpty()) {
+                log.info("[支付分同步] 无待同步的 USER_PAYING 订单");
+                return;
+            }
+
+            log.info("[支付分同步] 找到 {} 个待同步订单", payingOrders.size());
+
+            int syncedCount = 0;
+            int stillPayingCount = 0;
+
+            for (Order order : payingOrders) {
+                try {
+                    boolean synced = payScoreService.syncPayScoreStatus(order.getId());
+                    if (synced) {
+                        syncedCount++;
+                    }
+
+                    // 重新查询订单确认最新状态
+                    Order updatedOrder = orderService.getById(order.getId());
+                    if (updatedOrder != null && PayScoreState.isPaying(updatedOrder.getPayScoreState())) {
+                        stillPayingCount++;
+
+                        // 检查是否超过 24 小时
+                        if (updatedOrder.getServiceEndTime() != null) {
+                            long hoursElapsed = ChronoUnit.HOURS.between(
+                                    updatedOrder.getServiceEndTime(), LocalDateTime.now());
+                            if (hoursElapsed >= OVERDUE_HOURS) {
+                                log.warn("[支付分同步] 订单长时间未支付 - orderId: {}, 已过 {} 小时, failReason: {}",
+                                        updatedOrder.getId(), hoursElapsed, updatedOrder.getPayScoreFailReason());
+                            }
+                        }
+                    }
+                } catch (Exception e) {
+                    log.error("[支付分同步] 同步订单状态异常 - orderId: {}", order.getId(), e);
+                }
+            }
+
+            log.info("[支付分同步] 同步完成 - 共处理 {} 单, 状态变更 {} 单, 仍待支付 {} 单",
+                    payingOrders.size(), syncedCount, stillPayingCount);
+
+        } catch (Exception e) {
+            log.error("[支付分同步] 定时任务执行失败", e);
+        } finally {
+            TraceIdUtils.removeTraceId();
+        }
+    }
+}

+ 2 - 2
haha-admin/src/main/resources/application.yml

@@ -100,8 +100,8 @@ sa-token:
 wechat:
   # 管理端小程序(haha-admin-mp)配置 - 用于补货员微信登录等
   admin-miniapp:
-    app-id: wx_admin_mp_appid
-    secret: wx_admin_mp_secret
+    app-id: 2601051549145878 # todo 要替换
+    secret: 06e1be59332b00de0baad82002cdbcb5 # todo 要替换
 
 # 哈哈零售 API 配置
 haha:

+ 5 - 0
haha-common/src/main/java/com/haha/common/enums/PayScoreState.java

@@ -7,6 +7,7 @@ public enum PayScoreState {
 
     CREATED("CREATED", "已创建"),
     DOING("DOING", "进行中"),
+    USER_PAYING("USER_PAYING", "用户支付中"),
     DONE("DONE", "已完成"),
     REVOKED("REVOKED", "已取消");
 
@@ -34,6 +35,10 @@ public enum PayScoreState {
         return DONE.code.equals(code) || REVOKED.code.equals(code);
     }
 
+    public static boolean isPaying(String code) {
+        return USER_PAYING.code.equals(code);
+    }
+
     public static boolean isDone(String code) {
         return DONE.code.equals(code);
     }

+ 3 - 0
haha-entity/src/main/java/com/haha/entity/Order.java

@@ -83,6 +83,9 @@ public class Order implements Serializable {
     /** 支付分订单状态: CREATED/DOING/USER_PAYING/DONE */
     private String payScoreState;
 
+    /** 支付分扣款失败原因(扣款失败时记录微信返回的错误信息) */
+    private String payScoreFailReason;
+
     /** 支付分服务ID */
     private String serviceId;
 

+ 3 - 3
haha-miniapp/src/main/resources/application.yml

@@ -86,9 +86,9 @@ wechat:
   #   service-id: YOUR_PAY_SCORE_SERVICE_ID
   #   pay-score-notify-url: http://localhost:7077/api/payment/callback/wechat_payscore
   # 小程序配置
-  miniapp:
-    app-id: wxb1cbe4678e0175f2
-    secret: 26c00fd986c628799653aca98803e5a5
+  admin-miniapp:
+    app-id: wxb1cbe4678e0175f2 # todo 要替换
+    secret: 26c00fd986c628799653aca98803e5a5 # todo 要替换
 
 
 # 哈哈零兽配置

+ 11 - 2
haha-service/src/main/java/com/haha/service/impl/HahaCallbackServiceImpl.java

@@ -1332,8 +1332,17 @@ public class HahaCallbackServiceImpl implements HahaCallbackService {
             PayScoreResult result = payScoreService.completePayScoreOrder(order.getId(), totalAmount, null);
 
             if (result.isSuccess()) {
-                log.info("支付分扣费成功 - orderId: {}, state: {}, outOrderNo: {}",
-                        order.getId(), result.getState(), result.getOutOrderNo());
+                String state = result.getState();
+                if (PayScoreState.DONE.getCode().equals(state)) {
+                    log.info("支付分扣费成功 - orderId: {}, state: {}, outOrderNo: {}",
+                            order.getId(), state, result.getOutOrderNo());
+                } else if (PayScoreState.isPaying(state)) {
+                    log.warn("支付分扣款已发起但用户待支付 - orderId: {}, state: {}, outOrderNo: {}",
+                            order.getId(), state, result.getOutOrderNo());
+                } else {
+                    log.info("支付分扣费完成 - orderId: {}, state: {}, outOrderNo: {}",
+                            order.getId(), state, result.getOutOrderNo());
+                }
             } else {
                 log.error("支付分扣费失败 - orderId: {}, errorCode: {}, errorMsg: {}",
                         order.getId(), result.getErrorCode(), result.getErrorMsg());

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

@@ -159,16 +159,35 @@ public class PayScoreServiceImpl implements PayScoreService {
         PayScoreResult result = payScoreStrategy.completeServiceOrder(request);
 
         if (result.isSuccess()) {
-            order.setPayScoreState(result.getState() != null ? result.getState() : "USER_PAYING");
+            // 微信 API 调用成功,根据返回的 state 更新订单
+            String state = result.getState() != null ? result.getState() : "USER_PAYING";
+            order.setPayScoreState(state);
             order.setServiceEndTime(LocalDateTime.now());
             order.setTotalAmount(totalAmount);
-            orderService.updateById(order);
 
-            log.info("[支付分服务] 完结服务订单成功 - orderId: {}, state: {}", 
-                    orderId, result.getState());
+            if (PayScoreState.DONE.getCode().equals(state)) {
+                log.info("[支付分服务] 完结并扣款成功 - orderId: {}, state: {}", orderId, state);
+            } else if (PayScoreState.isPaying(state)) {
+                log.warn("[支付分服务] 完结成功但用户待支付 - orderId: {}, state: {}", orderId, state);
+            } else {
+                log.info("[支付分服务] 完结服务订单成功 - orderId: {}, state: {}", orderId, state);
+            }
+
+            orderService.updateById(order);
         } else {
-            log.error("[支付分服务] 完结服务订单失败 - orderId: {}, errorCode: {}, errorMsg: {}", 
-                    orderId, result.getErrorCode(), result.getErrorMsg());
+            // 微信 API 调用失败(网络异常、业务拒绝等),标记为 USER_PAYING 避免订单卡死
+            String failReason = result.getErrorCode() + ": " + result.getErrorMsg();
+            log.error("[支付分服务] 完结服务订单失败 - orderId: {}, failReason: {}", orderId, failReason);
+
+            order.setPayScoreState(PayScoreState.USER_PAYING.getCode());
+            order.setPayScoreFailReason(failReason);
+            order.setServiceEndTime(LocalDateTime.now());
+            order.setTotalAmount(totalAmount);
+            orderService.updateById(order);
+
+            // 本地已正确处理为 USER_PAYING 中间态,返回 success
+            return PayScoreResult.success(
+                    order.getPayScoreOrderId(), PayScoreState.USER_PAYING.getCode());
         }
 
         return result;
@@ -270,11 +289,17 @@ public class PayScoreServiceImpl implements PayScoreService {
             order.setPayStatus(PayStatus.PAID.getCode());
             order.setStatus(OrderStatus.COMPLETED.getCode());
             order.setPayTime(result.getPayTime() != null ? result.getPayTime() : LocalDateTime.now());
+            order.setPayScoreFailReason(null);
             if (result.getTotalAmount() != null) {
                 order.setTotalAmount(result.getTotalAmount());
             }
             log.info("[支付分服务] 用户完成支付 - orderId: {}, amount: {}元", 
                     order.getId(), result.getTotalAmount());
+        } else if (PayScoreState.isPaying(state)) {
+            // 订单进入 USER_PAYING 状态(扣款已发起但用户尚未完成支付)
+            order.setPayScoreState(PayScoreState.USER_PAYING.getCode());
+            log.warn("[支付分服务] 订单进入用户支付中状态 - orderId: {}, state: {}", 
+                    order.getId(), state);
         }
 
         orderService.updateById(order);
@@ -316,11 +341,14 @@ public class PayScoreServiceImpl implements PayScoreService {
             if (PayScoreState.isDone(newState)) {
                 order.setPayStatus(PayStatus.PAID.getCode());
                 order.setStatus(OrderStatus.COMPLETED.getCode());
+                order.setPayScoreFailReason(null);
                 if (result.getTotalAmount() != null) {
                     order.setTotalAmount(result.getTotalAmount());
                 }
             } else if (PayScoreState.isRevoked(newState)) {
                 order.setStatus(OrderStatus.CANCELLED.getCode());
+            } else if (PayScoreState.isPaying(newState)) {
+                log.info("[支付分服务] 订单仍处于用户支付中状态 - orderId: {}", orderId);
             }
 
             orderService.updateById(order);