Просмотр исходного кода

feat(order): 订单支付成功 webhook 通知(cfOrder),覆盖商品订单与会员付费

- ProductOrderService.doHandlePaymentSuccess 末尾挂载:组装订单号/商品x数量/金额(分转元)/下单人昵称/收件人(由 addressSnapshot JSON 解析,无地址显示自提/无需配送),推送 sendWaitMsg(content, "cfOrder")
- MembershipService.activateMembership 末尾挂载:会员订阅订单号/等级/周期/金额(分转元)/开通人,推送同 label
- 均 try/catch+WARN 不阻断主流程;OrderWebhookNotifyTest 5 用例验证组装与金额换算
Xiaogang Liao 3 дней назад
Родитель
Сommit
a926192592

+ 35 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/MembershipService.java

@@ -47,6 +47,9 @@ public class MembershipService implements MembershipServiceInterface {
     @Resource
     private PaymentOrderMapper paymentOrderMapper;
 
+    @Resource
+    private WechatWaitMsgService wechatWaitMsgService;
+
     // @Lazy 打破 MembershipService ↔ CfCommissionService → CfEarnDispatchService → CfBalanceService 循环依赖
     @Resource
     @Lazy
@@ -856,6 +859,13 @@ public class MembershipService implements MembershipServiceInterface {
 
             log.info("会员开通完成: orderNo={}, levelCode={}, amount={}, fromLevel={}",
                     order.getOrderNo(), order.getLevelCode(), order.getAmount(), fromLevel);
+
+            // 会员付费 webhook 通知(不阻塞开通流程)
+            try {
+                notifyMembershipPaidWebhook(order, adminUser);
+            } catch (Exception e) {
+                log.warn("会员付费 webhook 通知异常(不阻塞): orderNo={}", order.getOrderNo());
+            }
             return true;
         } catch (Exception e) {
             log.error("会员开通异常: orderNo={}", order.getOrderNo(), e);
@@ -2085,4 +2095,29 @@ public class MembershipService implements MembershipServiceInterface {
         }
         return row;
     }
+
+    /**
+     * 会员付费 webhook 通知:推送会员开通信息到微信待办。金额分转元,失败仅 WARN。
+     */
+    private void notifyMembershipPaidWebhook(PaymentOrder order, User adminUser) {
+        try {
+            StringBuilder sb = new StringBuilder();
+            String buyerName = "";
+            if (adminUser != null) {
+                buyerName = adminUser.getNickname() != null ? adminUser.getNickname() : String.valueOf(adminUser.getId());
+            }
+            Integer payCent = order.getAmount() != null ? order.getAmount() : 0;
+            double yuan = payCent / 100.0;
+            sb.append("【新订单】").append(order.getOrderNo()).append("\n");
+            sb.append("商品: 会员订阅 - ").append(order.getLevelCode() == null ? "" : order.getLevelCode());
+            if (order.getPeriod() != null) sb.append(" / ").append(order.getPeriod());
+            sb.append("\n");
+            sb.append(String.format("金额: ¥%.2f", yuan)).append("\n");
+            sb.append("下单人: ").append(buyerName == null || buyerName.isEmpty() ? "用户" : buyerName);
+
+            wechatWaitMsgService.sendWaitMsg(sb.toString(), "cfOrder");
+        } catch (Exception e) {
+            log.warn("会员付费 webhook 通知失败: orderNo={}, error={}", order.getOrderNo(), e.getMessage());
+        }
+    }
 }

+ 61 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ProductOrderService.java

@@ -118,6 +118,9 @@ public class ProductOrderService {
     @Resource
     private RedisLockUtil redisLockUtil;
 
+    @Resource
+    private WechatWaitMsgService wechatWaitMsgService;
+
     /** F-5 消费侧:商品购买 CF 值抵扣/退还改投统一账本 cf_balance(购买人 member 维度) */
     @Resource
     private CfSpendDispatchService cfSpendDispatchService;
@@ -1231,9 +1234,67 @@ public class ProductOrderService {
         } catch (Exception e) {
             log.error("虚拟商品自动发货失败(不阻塞支付): orderNo={}, error={}", orderNo, e.getMessage(), e);
         }
+        // 订单支付成功 webhook 通知(不阻塞支付流程)
+        try {
+            notifyOrderPaidWebhook(order);
+        } catch (Exception e) {
+            log.warn("订单支付成功 webhook 通知异常(不阻塞): orderNo={}, error={}", orderNo, e.getMessage());
+        }
         return Result.success("支付成功");
     }
 
+    /**
+     * 订单支付成功 webhook 通知:组装订单信息(含收件人)推送微信待办。
+     * 金额单位:数据库存分为单位,推送转元。失败仅记录 WARN,不抛异常。
+     */
+    private void notifyOrderPaidWebhook(ProductOrder order) {
+        try {
+            StringBuilder sb = new StringBuilder();
+            String buyerName = "";
+            try {
+                User buyer = userMapper.selectById(order.getBuyerId());
+                if (buyer != null) {
+                    buyerName = buyer.getNickname() != null ? buyer.getNickname() : "";
+                }
+            } catch (Exception e) {
+                log.warn("查询下单人昵称失败: buyerId={}", order.getBuyerId());
+            }
+
+            Integer payCent = order.getMoneyAmount() != null ? order.getMoneyAmount() : order.getTotalAmount();
+            double yuan = (payCent != null ? payCent : 0) / 100.0;
+            sb.append("【新订单】").append(order.getOrderNo()).append("\n");
+            sb.append("商品: ").append(order.getProductName() != null ? order.getProductName() : "")
+              .append(" x").append(order.getQuantity() != null ? order.getQuantity() : 1).append("\n");
+            sb.append(String.format("金额: ¥%.2f", yuan)).append("\n");
+            sb.append("下单人: ").append(buyerName == null || buyerName.isEmpty() ? "用户" + order.getBuyerId() : buyerName).append("\n");
+
+            // 收件人信息(addressSnapshot JSON 快照)
+            String addrJson = order.getAddressSnapshot();
+            if (addrJson != null && !addrJson.trim().isEmpty()) {
+                try {
+                    com.alibaba.fastjson.JSONObject addr = com.alibaba.fastjson.JSON.parseObject(addrJson);
+                    String receiver = addr.getString("receiverName");
+                    String phone = addr.getString("phone");
+                    String region = (addr.getString("province") == null ? "" : addr.getString("province"))
+                            + (addr.getString("city") == null ? "" : addr.getString("city"))
+                            + (addr.getString("district") == null ? "" : addr.getString("district"))
+                            + (addr.getString("street") == null ? "" : addr.getString("street"));
+                    sb.append("收件人: ").append(receiver == null ? "" : receiver)
+                      .append(" | ").append(phone == null ? "" : phone)
+                      .append(" | ").append(region);
+                } catch (Exception e) {
+                    log.warn("解析收件人信息失败: orderNo={}", order.getOrderNo());
+                }
+            } else {
+                sb.append("收件人: 自提/无需配送");
+            }
+
+            wechatWaitMsgService.sendWaitMsg(sb.toString(), "cfOrder");
+        } catch (Exception e) {
+            log.warn("订单 webhook 通知失败: orderNo={}, error={}", order.getOrderNo(), e.getMessage());
+        }
+    }
+
     public Result<String> cancel(String orderNo, Long userId) {
         ProductOrder order = orderMapper.selectOne(
             new LambdaQueryWrapper<ProductOrder>().eq(ProductOrder::getOrderNo, orderNo));

+ 159 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/OrderWebhookNotifyTest.java

@@ -0,0 +1,159 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.entity.PaymentOrder;
+import com.etotem.cfc.entity.ProductOrder;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.UserMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.util.Date;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.*;
+
+/**
+ * 订单支付成功 webhook 通知(cfOrder)组装测试。
+ * 覆盖:ProductOrderService.notifyOrderPaidWebhook(商品订单,含收件人+金额分转元)
+ *      + MembershipService.notifyMembershipPaidWebhook(会员付费,金额分转元)
+ * 模式:Mockito + ReflectionTestUtils,不起 Spring 上下文。
+ */
+public class OrderWebhookNotifyTest {
+
+    private ProductOrderService productOrderService;
+    private MembershipService membershipService;
+    private WechatWaitMsgService wechatWaitMsgService;
+    private UserMapper userMapper;
+
+    @BeforeEach
+    public void setUp() {
+        productOrderService = new ProductOrderService();
+        membershipService = new MembershipService();
+        wechatWaitMsgService = mock(WechatWaitMsgService.class);
+        userMapper = mock(UserMapper.class);
+
+        ReflectionTestUtils.setField(productOrderService, "wechatWaitMsgService", wechatWaitMsgService);
+        ReflectionTestUtils.setField(productOrderService, "userMapper", userMapper);
+        ReflectionTestUtils.setField(membershipService, "wechatWaitMsgService", wechatWaitMsgService);
+    }
+
+    @Test
+    public void 商品订单_包含订单号商品金额下单人收件人() {
+        ProductOrder order = new ProductOrder();
+        order.setOrderNo("O20261110TEST001");
+        order.setProductName("鳕鱼");
+        order.setQuantity(2);
+        order.setMoneyAmount(5990); // 分 → ¥59.90
+        order.setBuyerId(27L);
+        order.setAddressSnapshot("{\"receiverName\":\"张三\",\"phone\":\"13800138000\","
+                + "\"province\":\"北京市\",\"city\":\"北京市\",\"district\":\"朝阳区\",\"street\":\"望京街1号\"}");
+
+        User buyer = new User();
+        buyer.setId(27L);
+        buyer.setNickname("CF");
+        when(userMapper.selectById(27L)).thenReturn(buyer);
+
+        ReflectionTestUtils.invokeMethod(productOrderService, "notifyOrderPaidWebhook", order);
+
+        ArgumentCaptor<String> contentCaptor = ArgumentCaptor.forClass(String.class);
+        verify(wechatWaitMsgService).sendWaitMsg(contentCaptor.capture(), eq("cfOrder"));
+        String content = contentCaptor.getValue();
+        assertTrue(content.contains("O20261110TEST001"), "含订单号: " + content);
+        assertTrue(content.contains("鳕鱼 x2"), "含商品名与数量: " + content);
+        assertTrue(content.contains("¥59.90"), "金额分转元(5990→59.90): " + content);
+        assertTrue(content.contains("CF"), "含下单人昵称: " + content);
+        assertTrue(content.contains("张三"), "含收件人姓名: " + content);
+        assertTrue(content.contains("13800138000"), "含收件人电话: " + content);
+        assertTrue(content.contains("北京市北京市朝阳区望京街1号"), "含完整地址: " + content);
+    }
+
+    @Test
+    public void 商品订单_无地址快照_显示自提无需配送() {
+        ProductOrder order = new ProductOrder();
+        order.setOrderNo("O20261110TEST002");
+        order.setProductName("会员卡");
+        order.setQuantity(1);
+        order.setMoneyAmount(9900);
+        order.setBuyerId(34L);
+
+        User buyer = new User();
+        buyer.setId(34L);
+        buyer.setNickname("书宁宁");
+        when(userMapper.selectById(34L)).thenReturn(buyer);
+
+        ReflectionTestUtils.invokeMethod(productOrderService, "notifyOrderPaidWebhook", order);
+
+        ArgumentCaptor<String> contentCaptor = ArgumentCaptor.forClass(String.class);
+        verify(wechatWaitMsgService).sendWaitMsg(contentCaptor.capture(), eq("cfOrder"));
+        String content = contentCaptor.getValue();
+        assertTrue(content.contains("自提/无需配送"), "无地址时显示自提: " + content);
+    }
+
+    @Test
+    public void 会员付费_包含订单号等级金额下单人() {
+        PaymentOrder order = new PaymentOrder();
+        order.setOrderNo("M20261110TEST003");
+        order.setLevelCode("BASIC");
+        order.setPeriod("yearly");
+        order.setAmount(19900); // 分 → ¥199.00
+
+        User admin = new User();
+        admin.setId(27L);
+        admin.setNickname("CF");
+
+        ReflectionTestUtils.invokeMethod(membershipService, "notifyMembershipPaidWebhook", order, admin);
+
+        ArgumentCaptor<String> contentCaptor = ArgumentCaptor.forClass(String.class);
+        verify(wechatWaitMsgService).sendWaitMsg(contentCaptor.capture(), eq("cfOrder"));
+        String content = contentCaptor.getValue();
+        assertTrue(content.contains("M20261110TEST003"), "含订单号: " + content);
+        assertTrue(content.contains("BASIC"), "含等级: " + content);
+        assertTrue(content.contains("yearly"), "含订阅周期: " + content);
+        assertTrue(content.contains("¥199.00"), "金额分转元(19900→199.00): " + content);
+        assertTrue(content.contains("CF"), "含下单人: " + content);
+    }
+
+    @Test
+    public void 会员付费_金额为0_不下发空内容() {
+        PaymentOrder order = new PaymentOrder();
+        order.setOrderNo("M20261110TEST004");
+        order.setLevelCode("BASIC");
+        order.setAmount(null);
+
+        ReflectionTestUtils.invokeMethod(membershipService, "notifyMembershipPaidWebhook", order, null);
+
+        ArgumentCaptor<String> contentCaptor = ArgumentCaptor.forClass(String.class);
+        verify(wechatWaitMsgService).sendWaitMsg(contentCaptor.capture(), eq("cfOrder"));
+        String content = contentCaptor.getValue();
+        assertTrue(content.contains("¥0.00"), "金额为0正常显示: " + content);
+    }
+
+    @Test
+    public void 商品订单_金额同totalAmount_fallback() {
+        ProductOrder order = new ProductOrder();
+        order.setOrderNo("O20261110TEST005");
+        order.setProductName("测试商品");
+        order.setQuantity(1);
+        order.setMoneyAmount(null); // fallback 到 totalAmount
+        order.setTotalAmount(12345);
+        order.setBuyerId(105L);
+        order.setAddressSnapshot(null);
+
+        User buyer = new User();
+        buyer.setId(105L);
+        buyer.setNickname(null);
+        when(userMapper.selectById(105L)).thenReturn(buyer);
+
+        ReflectionTestUtils.invokeMethod(productOrderService, "notifyOrderPaidWebhook", order);
+
+        ArgumentCaptor<String> contentCaptor = ArgumentCaptor.forClass(String.class);
+        verify(wechatWaitMsgService).sendWaitMsg(contentCaptor.capture(), eq("cfOrder"));
+        String content = contentCaptor.getValue();
+        assertTrue(content.contains("¥123.45"), "fallback totalAmount 12345→123.45: " + content);
+        assertTrue(content.contains("用户105"), "昵称缺失回退用户ID: " + content);
+    }
+}