Эх сурвалжийг харах

feat: 虚拟支付超时兜底任务
pending订单query_order确认+超时关单,防success回调丢失

asus 3 сар өмнө
parent
commit
df7b479dc1

+ 101 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/VirtualPayService.java

@@ -1,5 +1,7 @@
 package com.etotem.cfc.service;
 package com.etotem.cfc.service;
 
 
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
 import com.etotem.cfc.common.HmacSignUtil;
 import com.etotem.cfc.common.HmacSignUtil;
 import com.etotem.cfc.dto.VirtualPayParamsDTO;
 import com.etotem.cfc.dto.VirtualPayParamsDTO;
 import com.etotem.cfc.entity.AssessmentOrder;
 import com.etotem.cfc.entity.AssessmentOrder;
@@ -8,7 +10,11 @@ import com.etotem.cfc.entity.PaymentOrder;
 import com.etotem.cfc.entity.VirtualGoodsConfig;
 import com.etotem.cfc.entity.VirtualGoodsConfig;
 import lombok.extern.slf4j.Slf4j;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
 import org.springframework.stereotype.Service;
 import org.springframework.stereotype.Service;
+import org.springframework.web.client.RestTemplate;
 
 
 import javax.annotation.Resource;
 import javax.annotation.Resource;
 import java.util.ArrayList;
 import java.util.ArrayList;
@@ -51,6 +57,14 @@ public class VirtualPayService {
     @Resource
     @Resource
     private MemberSubscriptionService memberSubscriptionService;
     private MemberSubscriptionService memberSubscriptionService;
 
 
+    @Resource
+    private WechatService wechatService;   // 复用 getAccessToken()(带缓存)
+
+    @Value("${wechat.test-mode:false}")
+    private boolean testMode;              // 测试模式:跳过微信 API 调用
+
+    private final RestTemplate restTemplate = new RestTemplate();  // query_order HTTP 调用
+
     @Value("${wechat.appid}")
     @Value("${wechat.appid}")
     private String appid;
     private String appid;
 
 
@@ -311,6 +325,93 @@ public class VirtualPayService {
         }
         }
     }
     }
 
 
+    /**
+     * 查询虚拟支付订单实际支付状态(防 success 回调丢失的兜底,spec §7)。
+     * 按官方规范:pay_sig = to_hex(hmac_sha256(appKey, "/xpay/query_order&" + post_body)),
+     * post_body 必须与真正发送的 HTTP body 完全一致。
+     *
+     * @param outTradeNo 业务订单号(A/ORD/SUB 前缀)
+     * @param openid     用户 openid(官方必填,调用方负责解析)
+     * @return 查询结果;查询失败/响应异常返回 null(调用方不得据此关单,防误杀已支付订单)
+     */
+    public QueryOrderResult queryOrder(String outTradeNo, String openid) {
+        // 1. 测试模式:不调微信,返回"未支付"模拟结果(status=1),让超时关单路径在测试环境可跑
+        if (testMode) {
+            log.info("【测试模式】跳过query_order查询: outTradeNo={}", outTradeNo);
+            return new QueryOrderResult(false, 1, null);
+        }
+        try {
+            // 2. 组装 body(LinkedHashMap 保序;order_id 传业务订单号)
+            Map<String, Object> body = new LinkedHashMap<>();
+            body.put("openid", openid);
+            body.put("env", env);
+            body.put("order_id", outTradeNo);
+            body.put("wx_order_id", "");
+
+            // 3. post_body = 序列化结果,签名与发送必须用同一字符串(官方强调完全一致)
+            String postBody = JSON.toJSONString(body);
+
+            // 4. pay_sig = hmac_sha256(appKey, "/xpay/query_order&" + postBody)
+            String paySig = buildQueryOrderPaySig(postBody);
+
+            // 5. HTTP 调用(access_token 复用 WechatService 缓存)
+            String url = "https://api.weixin.qq.com/xpay/query_order?access_token="
+                    + wechatService.getAccessToken() + "&pay_sig=" + paySig;
+
+            HttpHeaders headers = new HttpHeaders();
+            headers.setContentType(MediaType.APPLICATION_JSON);
+            HttpEntity<String> entity = new HttpEntity<>(postBody, headers);
+            String respBody = restTemplate.postForObject(url, entity, String.class);
+
+            // 6. 解析响应
+            JSONObject resp = JSON.parseObject(respBody);
+            int errcode = resp.getIntValue("errcode");
+            if (errcode != 0) {
+                log.warn("query_order返回错误: outTradeNo={}, errcode={}, errmsg={}",
+                        outTradeNo, errcode, resp.getString("errmsg"));
+                return null;   // 查询失败:调用方不得关单
+            }
+            JSONObject order = resp.getJSONObject("order");
+            if (order == null) {
+                log.warn("query_order响应缺少order: outTradeNo={}", outTradeNo);
+                return null;
+            }
+            int status = order.getIntValue("status");
+            String wxpayOrderId = order.getString("wxpay_order_id");
+            boolean paid = status == 2 || status == 3 || status == 4;  // 已支付判定
+            log.info("query_order结果: outTradeNo={}, status={}, paid={}", outTradeNo, status, paid);
+            return new QueryOrderResult(paid, status, wxpayOrderId);
+        } catch (Exception e) {
+            log.error("query_order查询异常: outTradeNo={}", outTradeNo, e);
+            return null;   // 异常:调用方不得关单
+        }
+    }
+
+    /**
+     * 构建 query_order 的 pay_sig(package-private,便于单测断言)。
+     * 官方规则:hmac_sha256(appKey, uri + '&' + post_body),uri=/xpay/query_order,
+     * post_body 为原始 JSON body 串(与 generatePayParams 的 buildSignData 字典序不同)。
+     */
+    String buildQueryOrderPaySig(String postBody) {
+        return HmacSignUtil.hmacSha256Hex(appKey, "/xpay/query_order&" + postBody);
+    }
+
+    /** query_order 查询结果(内部静态类,免新文件) */
+    public static class QueryOrderResult {
+        /** 是否已支付(status∈{2,3,4}) */
+        public final boolean paid;
+        /** 微信侧订单状态枚举 */
+        public final int status;
+        /** 微信交易单号(order.wxpay_order_id) */
+        public final String wxpayOrderId;
+
+        public QueryOrderResult(boolean paid, int status, String wxpayOrderId) {
+            this.paid = paid;
+            this.status = status;
+            this.wxpayOrderId = wxpayOrderId;
+        }
+    }
+
     /**
     /**
      * 按官方规则拼接签名数据串:key 按字典序排序,以 key=value 形式用 & 连接。
      * 按官方规则拼接签名数据串:key 按字典序排序,以 key=value 形式用 & 连接。
      * 官方规则要求 signData 不得包含 platform 字段(否则 -15005),此处统一剔除。
      * 官方规则要求 signData 不得包含 platform 字段(否则 -15005),此处统一剔除。

+ 232 - 0
cfc-backend/src/main/java/com/etotem/cfc/task/VirtualPayTimeoutTask.java

@@ -0,0 +1,232 @@
+package com.etotem.cfc.task;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.AssessmentOrder;
+import com.etotem.cfc.entity.Family;
+import com.etotem.cfc.entity.MemberSubscription;
+import com.etotem.cfc.entity.MemberSubscriptionOrder;
+import com.etotem.cfc.entity.PaymentOrder;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.AssessmentOrderMapper;
+import com.etotem.cfc.mapper.FamilyMapper;
+import com.etotem.cfc.mapper.MemberSubscriptionOrderMapper;
+import com.etotem.cfc.mapper.PaymentOrderMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.AssessmentOrderService;
+import com.etotem.cfc.service.MemberSubscriptionService;
+import com.etotem.cfc.service.MembershipService;
+import com.etotem.cfc.service.VirtualPayService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.Resource;
+import java.util.Calendar;
+import java.util.Date;
+import java.util.List;
+
+/**
+ * 虚拟支付超时兜底任务(spec §7:success 回调丢失防丢单)。
+ * 每分钟扫描三张订单表(测评/会员/订阅)status=pending 且创建超过 N 分钟的订单:
+ * 1. 调 VirtualPayService.queryOrder 向微信确认实际支付状态(兜底发货推送丢失)
+ * 2. 已支付 → 触发对应解锁逻辑(幂等,与发货推送同路径)
+ * 3. 明确未支付/已关闭 → 置 cancelled
+ * 4. 查询失败/无法解析 openid → 跳过本次(防误杀已支付订单),下轮再查
+ */
+@Component
+public class VirtualPayTimeoutTask {
+    private static final Logger log = LoggerFactory.getLogger(VirtualPayTimeoutTask.class);
+
+    @Resource
+    private VirtualPayService virtualPayService;
+
+    @Resource
+    private AssessmentOrderService assessmentOrderService;
+
+    @Resource
+    private MembershipService membershipService;
+
+    @Resource
+    private MemberSubscriptionService memberSubscriptionService;
+
+    @Resource
+    private AssessmentOrderMapper assessmentOrderMapper;
+
+    @Resource
+    private PaymentOrderMapper paymentOrderMapper;
+
+    @Resource
+    private MemberSubscriptionOrderMapper memberSubscriptionOrderMapper;
+
+    @Resource
+    private UserMapper userMapper;
+
+    @Resource
+    private FamilyMapper familyMapper;
+
+    @Value("${virtual-pay.payment-timeout-minutes:30}")
+    private int paymentTimeoutMinutes;
+
+    @Scheduled(fixedRate = 60000) // 每分钟执行一次
+    public void handleTimeoutOrders() {
+        Calendar cal = Calendar.getInstance();
+        cal.add(Calendar.MINUTE, -paymentTimeoutMinutes);
+        Date timeoutTime = cal.getTime();
+
+        // 1. 扫描三张表(status=pending 且创建超过 N 分钟)
+        LambdaQueryWrapper<AssessmentOrder> assessmentWrapper = new LambdaQueryWrapper<AssessmentOrder>()
+                .eq(AssessmentOrder::getStatus, "pending")
+                .lt(AssessmentOrder::getCreatedAt, timeoutTime);
+        List<AssessmentOrder> assessmentOrders = assessmentOrderMapper.selectList(assessmentWrapper);
+
+        LambdaQueryWrapper<PaymentOrder> paymentWrapper = new LambdaQueryWrapper<PaymentOrder>()
+                .eq(PaymentOrder::getStatus, "pending")
+                .lt(PaymentOrder::getCreatedAt, timeoutTime);
+        List<PaymentOrder> paymentOrders = paymentOrderMapper.selectList(paymentWrapper);
+
+        LambdaQueryWrapper<MemberSubscriptionOrder> subWrapper = new LambdaQueryWrapper<MemberSubscriptionOrder>()
+                .eq(MemberSubscriptionOrder::getStatus, "pending")
+                .lt(MemberSubscriptionOrder::getCreatedAt, timeoutTime);
+        List<MemberSubscriptionOrder> subOrders = memberSubscriptionOrderMapper.selectList(subWrapper);
+
+        // 2. 每单处理:解析 openid → queryOrder → 已支付解锁 / 未支付关单 / 失败跳过
+        int assessmentHandled = 0;
+        int membershipHandled = 0;
+        int subscriptionHandled = 0;
+        for (AssessmentOrder order : assessmentOrders) {
+            if (handleAssessmentOrder(order)) {
+                assessmentHandled++;
+            }
+        }
+        for (PaymentOrder order : paymentOrders) {
+            if (handlePaymentOrder(order)) {
+                membershipHandled++;
+            }
+        }
+        for (MemberSubscriptionOrder order : subOrders) {
+            if (handleSubscriptionOrder(order)) {
+                subscriptionHandled++;
+            }
+        }
+
+        // 3. 汇总日志
+        log.info("虚拟支付超时兜底扫描完成: 测评={}/{}, 会员={}/{}, 订阅={}/{}",
+                assessmentHandled, assessmentOrders.size(),
+                membershipHandled, paymentOrders.size(),
+                subscriptionHandled, subOrders.size());
+    }
+
+    /** A 前缀(测评订单)单笔处理,返回是否处理(解锁/关单) */
+    private boolean handleAssessmentOrder(AssessmentOrder order) {
+        // openid = order.getUserId() → User.getOpenid()(A 单买家即下单用户)
+        User user = userMapper.selectById(order.getUserId());
+        if (user == null || user.getOpenid() == null || user.getOpenid().isEmpty()) {
+            log.warn("虚拟支付超时兜底: 测评订单openid无法解析, 跳过不关单: orderNo={}", order.getOrderNo());
+            return false;
+        }
+        VirtualPayService.QueryOrderResult result = virtualPayService.queryOrder(order.getOrderNo(), user.getOpenid());
+        if (result == null) {
+            log.warn("虚拟支付超时兜底: 测评订单queryOrder失败, 跳过不关单: orderNo={}", order.getOrderNo());
+            return false;
+        }
+        if (result.paid) {
+            // 幂等:已 paid/refunded 返回 true,防重复佣金
+            boolean ok = assessmentOrderService.handleVirtualPaySuccess(order.getOrderNo(), result.wxpayOrderId);
+            log.info("虚拟支付超时兜底: 测评订单已支付, 触发解锁: orderNo={}, ok={}", order.getOrderNo(), ok);
+            return true;
+        }
+        if (result.status == 0 || result.status == 1 || result.status == 6) {
+            // 明确未支付/已关闭 → 置 cancelled
+            order.setStatus("cancelled");
+            order.setUpdatedAt(new Date());
+            assessmentOrderMapper.updateById(order);
+            log.info("虚拟支付超时兜底: 测评订单未支付, 置cancelled: orderNo={}, status={}",
+                    order.getOrderNo(), result.status);
+            return true;
+        }
+        // status∈{5,7,8,9,10} 退款相关 → 跳过,不关单
+        log.info("虚拟支付超时兜底: 测评订单退款相关状态, 跳过: orderNo={}, status={}", order.getOrderNo(), result.status);
+        return false;
+    }
+
+    /** ORD 前缀(会员订单)单笔处理,返回是否处理(解锁/关单) */
+    private boolean handlePaymentOrder(PaymentOrder order) {
+        String openid = resolveFamilyCreatorOpenid(order.getFamilyId());
+        if (openid == null) {
+            log.warn("虚拟支付超时兜底: 会员订单openid无法解析, 跳过不关单: orderNo={}", order.getOrderNo());
+            return false;
+        }
+        VirtualPayService.QueryOrderResult result = virtualPayService.queryOrder(order.getOrderNo(), openid);
+        if (result == null) {
+            log.warn("虚拟支付超时兜底: 会员订单queryOrder失败, 跳过不关单: orderNo={}", order.getOrderNo());
+            return false;
+        }
+        if (result.paid) {
+            // 幂等:paid 返回 true;trial 直接 true
+            boolean ok = membershipService.processPaymentCallback(order.getOrderNo(), result.wxpayOrderId, "virtual");
+            log.info("虚拟支付超时兜底: 会员订单已支付, 触发开通: orderNo={}, ok={}", order.getOrderNo(), ok);
+            return true;
+        }
+        if (result.status == 0 || result.status == 1 || result.status == 6) {
+            order.setStatus("cancelled");
+            order.setUpdatedAt(new Date());
+            paymentOrderMapper.updateById(order);
+            log.info("虚拟支付超时兜底: 会员订单未支付, 置cancelled: orderNo={}, status={}",
+                    order.getOrderNo(), result.status);
+            return true;
+        }
+        // status∈{5,7,8,9,10} 退款相关 → 跳过,不关单
+        log.info("虚拟支付超时兜底: 会员订单退款相关状态, 跳过: orderNo={}, status={}", order.getOrderNo(), result.status);
+        return false;
+    }
+
+    /** SUB 前缀(订阅订单)单笔处理,返回是否处理(解锁/关单) */
+    private boolean handleSubscriptionOrder(MemberSubscriptionOrder order) {
+        String openid = resolveFamilyCreatorOpenid(order.getFamilyId());
+        if (openid == null) {
+            log.warn("虚拟支付超时兜底: 订阅订单openid无法解析, 跳过不关单: orderNo={}", order.getOrderNo());
+            return false;
+        }
+        VirtualPayService.QueryOrderResult result = virtualPayService.queryOrder(order.getOrderNo(), openid);
+        if (result == null) {
+            log.warn("虚拟支付超时兜底: 订阅订单queryOrder失败, 跳过不关单: orderNo={}", order.getOrderNo());
+            return false;
+        }
+        if (result.paid) {
+            // 幂等:已 paid/refunded return null
+            MemberSubscription activated = memberSubscriptionService.activateSubscription(order.getFamilyId(),
+                    order.getLevel(), order.getAmount(), "virtual", result.wxpayOrderId, order.getOrderNo());
+            log.info("虚拟支付超时兜底: 订阅订单已支付, 触发激活: orderNo={}, activated={}",
+                    order.getOrderNo(), activated != null);
+            return true;
+        }
+        if (result.status == 0 || result.status == 1 || result.status == 6) {
+            order.setStatus("cancelled");
+            memberSubscriptionOrderMapper.updateById(order);
+            log.info("虚拟支付超时兜底: 订阅订单未支付, 置cancelled: orderNo={}, status={}",
+                    order.getOrderNo(), result.status);
+            return true;
+        }
+        // status∈{5,7,8,9,10} 退款相关 → 跳过,不关单
+        log.info("虚拟支付超时兜底: 订阅订单退款相关状态, 跳过: orderNo={}, status={}", order.getOrderNo(), result.status);
+        return false;
+    }
+
+    /**
+     * 解析家庭创建者(管理员)的 openid(ORD/SUB 单范式,同 MembershipService.processPaymentCallback)。
+     * 家庭或创建者缺失 → 返回 null(调用方不得关单)。
+     */
+    private String resolveFamilyCreatorOpenid(Long familyId) {
+        Family family = familyMapper.selectById(familyId);
+        if (family == null || family.getCreatorId() == null) {
+            return null;
+        }
+        User buyer = userMapper.selectById(family.getCreatorId());
+        if (buyer == null || buyer.getOpenid() == null || buyer.getOpenid().isEmpty()) {
+            return null;
+        }
+        return buyer.getOpenid();
+    }
+}

+ 3 - 0
cfc-backend/src/main/resources/application.yml

@@ -111,6 +111,9 @@ shop:
   order:
   order:
     payment-timeout-minutes: 30  # 订单超时时间(分钟)
     payment-timeout-minutes: 30  # 订单超时时间(分钟)
 
 
+virtual-pay:
+  payment-timeout-minutes: 30  # 虚拟支付订单超时时间(分钟)
+
 logging:
 logging:
   level:
   level:
     com.etotem.cfc: debug
     com.etotem.cfc: debug

+ 34 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/VirtualPayServiceTest.java

@@ -10,6 +10,7 @@ import static org.mockito.Mockito.when;
 
 
 import java.lang.reflect.Field;
 import java.lang.reflect.Field;
 import java.util.HashMap;
 import java.util.HashMap;
+import java.util.LinkedHashMap;
 import java.util.Map;
 import java.util.Map;
 
 
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.Test;
@@ -18,6 +19,7 @@ import org.mockito.InjectMocks;
 import org.mockito.Mock;
 import org.mockito.Mock;
 import org.mockito.junit.jupiter.MockitoExtension;
 import org.mockito.junit.jupiter.MockitoExtension;
 
 
+import com.alibaba.fastjson.JSON;
 import com.etotem.cfc.common.HmacSignUtil;
 import com.etotem.cfc.common.HmacSignUtil;
 import com.etotem.cfc.dto.VirtualPayParamsDTO;
 import com.etotem.cfc.dto.VirtualPayParamsDTO;
 import com.etotem.cfc.entity.MemberSubscriptionOrder;
 import com.etotem.cfc.entity.MemberSubscriptionOrder;
@@ -39,6 +41,9 @@ class VirtualPayServiceTest {
     @Mock
     @Mock
     private VirtualGoodsConfigService virtualGoodsConfigService;
     private VirtualGoodsConfigService virtualGoodsConfigService;
 
 
+    @Mock
+    private WechatService wechatService;
+
     @InjectMocks
     @InjectMocks
     private VirtualPayService virtualPayService;
     private VirtualPayService virtualPayService;
 
 
@@ -258,6 +263,35 @@ class VirtualPayServiceTest {
         assertNull(virtualPayService.generateSubscriptionPayParams("SUB20260802002", "test_session_key"));
         assertNull(virtualPayService.generateSubscriptionPayParams("SUB20260802002", "test_session_key"));
     }
     }
 
 
+    @Test
+    void queryOrder_testModeReturnsUnpaidWithoutHttp() throws Exception {
+        setField("testMode", true);
+        setField("env", 0);
+        VirtualPayService.QueryOrderResult result = virtualPayService.queryOrder("A20260802001", "test_openid");
+        assertNotNull(result);
+        assertFalse(result.paid);
+        assertEquals(1, result.status);
+        // 测试模式不触发 HTTP(wechatService 未被调用)
+        verify(wechatService, times(0)).getAccessToken();
+    }
+
+    @Test
+    void queryOrder_buildsPaySigWithUriAndRawBody() throws Exception {
+        // 对照官方规则: pay_sig = hmacSha256Hex(appKey, "/xpay/query_order&" + post_body)
+        setField("appKey", "test_app_key");
+        setField("env", 0);
+        // 验证签名拼接规则:uri 前缀 + 原始 body(与 generatePayParams 的 buildSignData 不同!)
+        String postBody = JSON.toJSONString(new LinkedHashMap<String, Object>() {{
+            put("openid", "test_openid");
+            put("env", 0);
+            put("order_id", "A20260802001");
+            put("wx_order_id", "");
+        }});
+        String expectedPaySig = HmacSignUtil.hmacSha256Hex("test_app_key", "/xpay/query_order&" + postBody);
+        // package-private buildQueryOrderPaySig 直接断言签名构建逻辑
+        assertEquals(expectedPaySig, virtualPayService.buildQueryOrderPaySig(postBody));
+    }
+
     private void setField(String name, Object value) throws Exception {
     private void setField(String name, Object value) throws Exception {
         Field field = VirtualPayService.class.getDeclaredField(name);
         Field field = VirtualPayService.class.getDeclaredField(name);
         field.setAccessible(true);
         field.setAccessible(true);