Explorar o código

feat: 会员订单改虚拟支付
createOrder返回VirtualPayParams,发货推送复用processPaymentCallback,修正等级硬编码FAMILY

asus hai 3 meses
pai
achega
6eb4671035

+ 31 - 2
cfc-backend/src/main/java/com/etotem/cfc/controller/MembershipController.java

@@ -10,8 +10,10 @@ import com.etotem.cfc.mapper.MemberUpgradeRecordMapper;
 import com.etotem.cfc.mapper.UserMapper;
 import com.etotem.cfc.service.MembershipService;
 import com.etotem.cfc.service.SysConfigService;
+import com.etotem.cfc.service.VirtualPayService;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.beans.factory.annotation.Value;
 import org.springframework.web.bind.annotation.*;
 
 import javax.annotation.Resource;
@@ -37,6 +39,12 @@ public class MembershipController {
     @Resource
     private SysConfigService sysConfigService;
 
+    @Value("${wechat.test-mode:false}")
+    private boolean testMode;
+
+    @Resource
+    private VirtualPayService virtualPayService;
+
     /**
      * 获取所有会员等级
      */
@@ -198,7 +206,7 @@ public class MembershipController {
      */
     @Operation(summary = "创建订单")
     @PostMapping("/orders")
-    public Result<PaymentOrderDTO> createOrder(@RequestBody Map<String, Object> params,
+    public Result<Object> createOrder(@RequestBody Map<String, Object> params,
         @RequestAttribute("userId") Long userId) {
         if (userId == null) {
             return Result.error("用户未登录");
@@ -211,7 +219,28 @@ public class MembershipController {
         Long couponId = params.get("couponId") != null ? Long.valueOf(params.get("couponId").toString()) : null;
 
         PaymentOrderDTO order = membershipService.createOrder(userId, familyId, levelCode, paymentType, period, couponId);
-        return Result.success(order);
+
+        // trial 单:createOrder 内已直接开通(status=paid),无需支付,原样返回
+        if ("trial".equals(paymentType)) {
+            return Result.success(order);
+        }
+
+        // testMode 兼容:返回订单信息,前端继续走 /notify 模拟支付(保留现有模拟逻辑)
+        if (testMode) {
+            return Result.success(order);
+        }
+
+        // sessionKey 后端从登录态取(users.session_key,@JsonIgnore 防泄露),不由前端传
+        User user = userMapper.selectById(userId);
+        if (user == null || user.getSessionKey() == null || user.getSessionKey().isEmpty()) {
+            return Result.error(400, "会话失效,请重新登录");
+        }
+
+        VirtualPayParamsDTO dto = virtualPayService.generateMembershipPayParams(order.getOrderNo(), user.getSessionKey());
+        if (dto == null) {
+            return Result.error(46001, "该商品暂未开放购买");
+        }
+        return Result.success(dto);
     }
 
     /**

+ 13 - 3
cfc-backend/src/main/java/com/etotem/cfc/service/MembershipService.java

@@ -218,6 +218,15 @@ public class MembershipService implements MembershipServiceInterface {
         }
     }
 
+    /**
+     * 按订单号查询会员订单(虚拟支付编排用)
+     */
+    public PaymentOrder getByOrderNo(String orderNo) {
+        return paymentOrderMapper.selectOne(
+                new LambdaQueryWrapper<PaymentOrder>()
+                        .eq(PaymentOrder::getOrderNo, orderNo));
+    }
+
     // ==================== 订单与支付 ====================
 
     /**
@@ -378,8 +387,9 @@ public class MembershipService implements MembershipServiceInterface {
             membershipMapper.insert(membership);
 
             // 升级用户等级
+            String fromLevel = adminUser.getMemberLevel() != null ? adminUser.getMemberLevel() : "FREE";
             Date expireTime = new Date(System.currentTimeMillis() + 365L * 24 * 60 * 60 * 1000);
-            adminUser.setMemberLevel("FAMILY");
+            adminUser.setMemberLevel(order.getLevelCode());
             adminUser.setMemberExpireTime(expireTime);
             adminUser.setUpdatedAt(new Date());
             userMapper.updateById(adminUser);
@@ -387,8 +397,8 @@ public class MembershipService implements MembershipServiceInterface {
             // 创建升级记录
             MemberUpgradeRecord upgradeRecord = new MemberUpgradeRecord();
             upgradeRecord.setUserId(adminUserId);
-            upgradeRecord.setFromLevel("FREE");
-            upgradeRecord.setToLevel("FAMILY");
+            upgradeRecord.setFromLevel(fromLevel);
+            upgradeRecord.setToLevel(order.getLevelCode());
             upgradeRecord.setUpgradeType("pay");
             upgradeRecord.setOrderId(order.getId());
             upgradeRecord.setExpireTime(expireTime);

+ 40 - 3
cfc-backend/src/main/java/com/etotem/cfc/service/VirtualPayService.java

@@ -3,6 +3,7 @@ package com.etotem.cfc.service;
 import com.etotem.cfc.common.HmacSignUtil;
 import com.etotem.cfc.dto.VirtualPayParamsDTO;
 import com.etotem.cfc.entity.AssessmentOrder;
+import com.etotem.cfc.entity.PaymentOrder;
 import com.etotem.cfc.entity.VirtualGoodsConfig;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Value;
@@ -43,6 +44,9 @@ public class VirtualPayService {
     @Resource
     private PackagePurchaseService packagePurchaseService;
 
+    @Resource
+    private MembershipService membershipService;
+
     @Value("${wechat.appid}")
     private String appid;
 
@@ -159,6 +163,36 @@ public class VirtualPayService {
         return generatePayParams(config, orderNo, totalFee, order.getPackageName(), attach, sessionKey);
     }
 
+    /**
+     * 会员订单虚拟支付下单参数编排(防循环依赖:编排放本类,MembershipService 单向被依赖)。
+     * 订单保持 pending;查不到道具映射返回 null(调用方转 46001 "该商品暂未开放购买")。
+     */
+    public VirtualPayParamsDTO generateMembershipPayParams(String orderNo, String sessionKey) {
+        // 1. 查订单 + 校验 pending
+        PaymentOrder order = membershipService.getByOrderNo(orderNo);
+        if (order == null || !"pending".equals(order.getStatus())) {
+            log.warn("生成会员支付参数失败,订单不存在或非pending: orderNo={}", orderNo);
+            return null;
+        }
+
+        // 2. 查 virtual_goods_config:goodsType=MEMBERSHIP, bizKey=订单levelCode → null → 调用方 46001
+        VirtualGoodsConfig config = virtualGoodsConfigService.getEnabled("MEMBERSHIP", order.getLevelCode());
+        if (config == null) {
+            log.warn("会员道具未配置或停用: levelCode={}", order.getLevelCode());
+            return null;
+        }
+
+        // 3. 金额 = order.amount(服务端已算,不重算;避免前端篡改),校验 >= 1元
+        if (order.getAmount() == null || order.getAmount() < 100) {
+            log.warn("会员订单金额异常(<1元): orderNo={}, amount={}", orderNo, order.getAmount());
+            return null;
+        }
+
+        // 4. 组装 attach + product_info 并生成支付参数(订单保持 pending,不置 paid)
+        String attach = "membership:" + order.getLevelCode();
+        return generatePayParams(config, orderNo, order.getAmount(), config.getGoodsName(), attach, sessionKey);
+    }
+
     /**
      * 处理虚拟支付发货推送(xpay_goods_deliver_notify)。
      * 按 OutTradeNo 前缀路由:A→测评,ORD→会员(TODO Task 7),SUB→订阅(TODO Task 8)。
@@ -185,9 +219,12 @@ public class VirtualPayService {
                 }
                 // 测评订单:标记已付 + 佣金结算 + 预约确认
                 assessmentOrderService.handleVirtualPaySuccess(outTradeNo, transactionId);
-            } else if (outTradeNo.startsWith("ORD") || outTradeNo.startsWith("SUB")) {
-                // TODO Task 7/8: 会员(ORD) / 订阅(SUB) 标记已付 + 解锁权益,暂未接入,记录日志返回成功
-                log.warn("虚拟支付发货推送暂未接入会员/订阅处理: outTradeNo={}", outTradeNo);
+            } else if (outTradeNo.startsWith("ORD")) {
+                // 会员订单:复用 processPaymentCallback(幂等已内置 L341-343)完成全部开通
+                membershipService.processPaymentCallback(outTradeNo, transactionId, "virtual");
+            } else if (outTradeNo.startsWith("SUB")) {
+                // TODO Task 8: 订阅解锁,暂未接入,记录日志返回成功
+                log.warn("虚拟支付发货推送暂未接入订阅处理: outTradeNo={}", outTradeNo);
             } else {
                 log.warn("虚拟支付发货推送未知订单前缀: outTradeNo={}", outTradeNo);
             }

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

@@ -3,8 +3,10 @@ package com.etotem.cfc.service;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
 
 import java.lang.reflect.Field;
 import java.util.HashMap;
@@ -18,6 +20,7 @@ import org.mockito.junit.jupiter.MockitoExtension;
 
 import com.etotem.cfc.common.HmacSignUtil;
 import com.etotem.cfc.dto.VirtualPayParamsDTO;
+import com.etotem.cfc.entity.PaymentOrder;
 import com.etotem.cfc.entity.VirtualGoodsConfig;
 
 @ExtendWith(MockitoExtension.class)
@@ -26,6 +29,12 @@ class VirtualPayServiceTest {
     @Mock
     private AssessmentOrderService assessmentOrderService;
 
+    @Mock
+    private MembershipService membershipService;
+
+    @Mock
+    private VirtualGoodsConfigService virtualGoodsConfigService;
+
     @InjectMocks
     private VirtualPayService virtualPayService;
 
@@ -126,6 +135,61 @@ class VirtualPayServiceTest {
         assertEquals(HmacSignUtil.hmacSha256Hex("test_app_key", "requestVirtualPayment&" + expectedSignData), dto.getSign());
     }
 
+    @Test
+    void handleDeliverNotify_membershipPrefixRoutesToProcessPaymentCallback() {
+        Map<String, Object> payload = new HashMap<>();
+        payload.put("OutTradeNo", "ORD20260802001");
+        payload.put("TransactionId", "WX1234567890");
+
+        Map<String, Object> result = virtualPayService.handleDeliverNotify(payload);
+
+        assertEquals(0, result.get("ErrCode"));
+        assertEquals("success", result.get("ErrMsg"));
+        verify(membershipService, times(1)).processPaymentCallback("ORD20260802001", "WX1234567890", "virtual");
+    }
+
+    @Test
+    void generateMembershipPayParams_returnsDtoForConfiguredLevel() throws Exception {
+        setField("appid", "wx5ba8038ef16fb245");
+        setField("appKey", "test_app_key");
+        setField("mchId", "1703255039");
+        setField("env", 0);
+
+        PaymentOrder order = new PaymentOrder();
+        order.setOrderNo("ORD20260802001");
+        order.setLevelCode("FAMILY");
+        order.setAmount(131400);
+        order.setStatus("pending");
+        when(membershipService.getByOrderNo("ORD20260802001")).thenReturn(order);
+
+        VirtualGoodsConfig config = new VirtualGoodsConfig();
+        config.setProductId("offer_m1");
+        config.setGoodsName("家庭会员年卡");
+        when(virtualGoodsConfigService.getEnabled("MEMBERSHIP", "FAMILY")).thenReturn(config);
+
+        VirtualPayParamsDTO dto = virtualPayService.generateMembershipPayParams("ORD20260802001", "test_session_key");
+
+        assertNotNull(dto);
+        assertEquals("ORD20260802001", dto.getOrderInfo().get("out_trade_no"));
+        assertEquals(131400, dto.getOrderInfo().get("total_fee"));
+        assertEquals("membership:FAMILY", dto.getOrderInfo().get("attach"));
+        assertEquals("offer_m1", dto.getOrderInfo().get("offerId"));
+        assertEquals("家庭会员年卡", dto.getOrderInfo().get("product_info"));
+    }
+
+    @Test
+    void generateMembershipPayParams_returnsNullWhenConfigMissing() {
+        PaymentOrder order = new PaymentOrder();
+        order.setOrderNo("ORD20260802002");
+        order.setLevelCode("PREMIUM");
+        order.setAmount(1316800);
+        order.setStatus("pending");
+        when(membershipService.getByOrderNo("ORD20260802002")).thenReturn(order);
+        when(virtualGoodsConfigService.getEnabled("MEMBERSHIP", "PREMIUM")).thenReturn(null);
+
+        assertNull(virtualPayService.generateMembershipPayParams("ORD20260802002", "test_session_key"));
+    }
+
     private void setField(String name, Object value) throws Exception {
         Field field = VirtualPayService.class.getDeclaredField(name);
         field.setAccessible(true);