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

feat: 订阅订单改虚拟支付

subscribe拆分为createPendingOrder+activateSubscription,下单返回VirtualPayParams,SUB发货/退款推送接入
asus 3 месяцев назад
Родитель
Сommit
1cb805c37d

+ 25 - 30
cfc-backend/src/main/java/com/etotem/cfc/controller/subscription/SubscriptionController.java

@@ -1,18 +1,18 @@
 package com.etotem.cfc.controller.subscription;
 package com.etotem.cfc.controller.subscription;
 
 
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.common.Result;
-import com.etotem.cfc.entity.Family;
+import com.etotem.cfc.dto.VirtualPayParamsDTO;
 import com.etotem.cfc.entity.MemberSubscription;
 import com.etotem.cfc.entity.MemberSubscription;
+import com.etotem.cfc.entity.MemberSubscriptionOrder;
 import com.etotem.cfc.entity.PromotionTier;
 import com.etotem.cfc.entity.PromotionTier;
 import com.etotem.cfc.entity.SubscriptionBenefitLog;
 import com.etotem.cfc.entity.SubscriptionBenefitLog;
 import com.etotem.cfc.entity.User;
 import com.etotem.cfc.entity.User;
-import com.etotem.cfc.mapper.FamilyMapper;
 import com.etotem.cfc.mapper.MemberSubscriptionMapper;
 import com.etotem.cfc.mapper.MemberSubscriptionMapper;
 import com.etotem.cfc.mapper.SubscriptionBenefitLogMapper;
 import com.etotem.cfc.mapper.SubscriptionBenefitLogMapper;
 import com.etotem.cfc.mapper.UserMapper;
 import com.etotem.cfc.mapper.UserMapper;
 import com.etotem.cfc.service.MemberSubscriptionService;
 import com.etotem.cfc.service.MemberSubscriptionService;
-import com.etotem.cfc.service.PaymentService;
 import com.etotem.cfc.service.PromotionTierService;
 import com.etotem.cfc.service.PromotionTierService;
+import com.etotem.cfc.service.VirtualPayService;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.web.bind.annotation.*;
 import org.springframework.web.bind.annotation.*;
@@ -33,12 +33,6 @@ public class SubscriptionController {
     @Resource
     @Resource
     private PromotionTierService promotionTierService;
     private PromotionTierService promotionTierService;
 
 
-    @Resource
-    private PaymentService paymentService;
-
-    @Resource
-    private FamilyMapper familyMapper;
-
     @Resource
     @Resource
     private UserMapper userMapper;
     private UserMapper userMapper;
 
 
@@ -48,6 +42,9 @@ public class SubscriptionController {
     @Resource
     @Resource
     private SubscriptionBenefitLogMapper benefitLogMapper;
     private SubscriptionBenefitLogMapper benefitLogMapper;
 
 
+    @Resource
+    private VirtualPayService virtualPayService;
+
     @Resource
     @Resource
     private ObjectMapper objectMapper;
     private ObjectMapper objectMapper;
 
 
@@ -96,8 +93,9 @@ public class SubscriptionController {
      * 创建订阅订单(JSON body)
      * 创建订阅订单(JSON body)
      */
      */
     @PostMapping("/create")
     @PostMapping("/create")
-    public Result<Map<String, Object>> createOrder(@RequestAttribute Long familyId,
-                                                    @RequestBody Map<String, Object> body) {
+    public Result<Object> createOrder(@RequestAttribute Long familyId,
+                                      @RequestAttribute("userId") Long userId,
+                                      @RequestBody Map<String, Object> body) {
         String level = (String) body.get("level");
         String level = (String) body.get("level");
         if (level == null) {
         if (level == null) {
             return Result.error("缺少level参数");
             return Result.error("缺少level参数");
@@ -110,36 +108,30 @@ public class SubscriptionController {
             amount = "L2".equals(level) ? 131400 : 36500;
             amount = "L2".equals(level) ? 131400 : 36500;
         }
         }
         String paymentType = body.get("paymentType") != null ? (String) body.get("paymentType") : "wechat";
         String paymentType = body.get("paymentType") != null ? (String) body.get("paymentType") : "wechat";
-        String orderNo = "SUB" + System.currentTimeMillis();
 
 
         if (testMode) {
         if (testMode) {
-            subscriptionService.subscribe(familyId, level, amount, paymentType, null, orderNo);
+            MemberSubscriptionOrder order = subscriptionService.createPendingOrder(familyId, level, amount, paymentType);
+            subscriptionService.activateSubscription(familyId, level, amount, paymentType, null, order.getOrderNo());
             Map<String, Object> result = new HashMap<>();
             Map<String, Object> result = new HashMap<>();
-            result.put("orderNo", orderNo);
+            result.put("orderNo", order.getOrderNo());
             result.put("amount", amount);
             result.put("amount", amount);
             result.put("paymentUrl", null);
             result.put("paymentUrl", null);
             result.put("testMode", true);
             result.put("testMode", true);
             return Result.success(result);
             return Result.success(result);
         }
         }
 
 
-        Family family = familyMapper.selectById(familyId);
-        Long creatorId = family != null ? family.getCreatorId() : null;
-        User user = creatorId != null ? userMapper.selectById(creatorId) : null;
-        String openid = user != null ? user.getOpenid() : null;
-        Map<String, Object> payResult = paymentService.createWechatPrepay(orderNo, "订阅" + level, amount, openid);
-        if (payResult != null && payResult.containsKey("paymentUrl")) {
-            Map<String, Object> result = new HashMap<>();
-            result.put("orderNo", orderNo);
-            result.put("amount", amount);
-            result.put("paymentUrl", payResult.get("paymentUrl"));
-            return Result.success(result);
+        // sessionKey 后端从登录态取(users.session_key,@JsonIgnore 防泄露),不由前端传
+        User user = userMapper.selectById(userId);
+        if (user == null || user.getSessionKey() == null || user.getSessionKey().isEmpty()) {
+            return Result.error(400, "会话失效,请重新登录");
         }
         }
 
 
-        subscriptionService.subscribe(familyId, level, amount, paymentType, null, orderNo);
-        Map<String, Object> result = new HashMap<>();
-        result.put("orderNo", orderNo);
-        result.put("amount", amount);
-        return Result.success(result);
+        MemberSubscriptionOrder order = subscriptionService.createPendingOrder(familyId, level, amount, paymentType);
+        VirtualPayParamsDTO dto = virtualPayService.generateSubscriptionPayParams(order.getOrderNo(), user.getSessionKey());
+        if (dto == null) {
+            return Result.error(46001, "该商品暂未开放购买");
+        }
+        return Result.success(dto);
     }
     }
 
 
     /**
     /**
@@ -151,6 +143,9 @@ public class SubscriptionController {
                                    @RequestParam Integer amount,
                                    @RequestParam Integer amount,
                                    @RequestParam String paymentType,
                                    @RequestParam String paymentType,
                                    @RequestParam(required = false) String transactionId) {
                                    @RequestParam(required = false) String transactionId) {
+        if (!testMode) {
+            return Result.error("仅测试模式可用,正式订阅请走 /create 虚拟支付");
+        }
         String orderNo = "SUB" + System.currentTimeMillis();
         String orderNo = "SUB" + System.currentTimeMillis();
         subscriptionService.subscribe(familyId, level, amount, paymentType, transactionId, orderNo);
         subscriptionService.subscribe(familyId, level, amount, paymentType, transactionId, orderNo);
         return Result.success(null);
         return Result.success(null);

+ 78 - 11
cfc-backend/src/main/java/com/etotem/cfc/service/MemberSubscriptionService.java

@@ -10,12 +10,14 @@ import com.etotem.cfc.mapper.FamilyMapper;
 import com.etotem.cfc.mapper.MemberSubscriptionMapper;
 import com.etotem.cfc.mapper.MemberSubscriptionMapper;
 import com.etotem.cfc.mapper.MemberSubscriptionOrderMapper;
 import com.etotem.cfc.mapper.MemberSubscriptionOrderMapper;
 import com.etotem.cfc.mapper.UserMapper;
 import com.etotem.cfc.mapper.UserMapper;
+import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
 import org.springframework.stereotype.Service;
 
 
 import javax.annotation.Resource;
 import javax.annotation.Resource;
 import java.util.Date;
 import java.util.Date;
 import java.util.List;
 import java.util.List;
 
 
+@Slf4j
 @Service
 @Service
 public class MemberSubscriptionService {
 public class MemberSubscriptionService {
 
 
@@ -83,10 +85,48 @@ public class MemberSubscriptionService {
     // ==================== 订阅操作 ====================
     // ==================== 订阅操作 ====================
 
 
     /**
     /**
-     * 创建新订阅
+     * 按订单号查询订阅订单(虚拟支付编排用)
      */
      */
-    public MemberSubscription subscribe(Long familyId, String level, Integer amount,
-                                         String paymentType, String transactionId, String orderNo) {
+    public MemberSubscriptionOrder getByOrderNo(String orderNo) {
+        return orderMapper.selectOne(
+                new LambdaQueryWrapper<MemberSubscriptionOrder>()
+                        .eq(MemberSubscriptionOrder::getOrderNo, orderNo));
+    }
+
+    /**
+     * 创建订阅待支付订单(不开通权益,支付成功后由 activateSubscription 开通)
+     */
+    public MemberSubscriptionOrder createPendingOrder(Long familyId, String level, Integer amount, String paymentType) {
+        String orderNo = "SUB" + System.currentTimeMillis();
+        Date now = new Date();
+        MemberSubscriptionOrder order = new MemberSubscriptionOrder();
+        order.setFamilyId(familyId);
+        order.setLevel(level);
+        order.setAmount(amount);
+        order.setStatus("pending");
+        order.setPaymentType(paymentType);
+        order.setOrderNo(orderNo);
+        order.setCreatedAt(now);
+        orderMapper.insert(order);
+        return order;
+    }
+
+    /**
+     * 激活订阅:置旧订阅 expired → 插新订阅 → 订单置 paid → 分配管家 → 受益日志 → 佣金分享收益
+     * 幂等:订单不存在或已终态返回 null
+     */
+    public MemberSubscription activateSubscription(Long familyId, String level, Integer amount,
+                                                   String paymentType, String transactionId, String orderNo) {
+        MemberSubscriptionOrder order = getByOrderNo(orderNo);
+        if (order == null) {
+            log.warn("激活订阅失败,订单不存在: orderNo={}", orderNo);
+            return null;
+        }
+        if ("paid".equals(order.getStatus()) || "refunded".equals(order.getStatus())) {
+            log.info("激活订阅重复调用,订单已终态: orderNo={}, status={}", orderNo, order.getStatus());
+            return null;
+        }
+
         Date now = new Date();
         Date now = new Date();
         Date expireTime = calculateExpireTime(paymentType);
         Date expireTime = calculateExpireTime(paymentType);
 
 
@@ -110,18 +150,12 @@ public class MemberSubscriptionService {
         subscription.setUpdatedAt(now);
         subscription.setUpdatedAt(now);
         subscriptionMapper.insert(subscription);
         subscriptionMapper.insert(subscription);
 
 
-        // 创建订单记录
-        MemberSubscriptionOrder order = new MemberSubscriptionOrder();
-        order.setFamilyId(familyId);
-        order.setLevel(level);
-        order.setAmount(amount);
+        // 订单置已付(订单由 createPendingOrder 预先创建)
         order.setStatus("paid");
         order.setStatus("paid");
         order.setPaymentType(paymentType);
         order.setPaymentType(paymentType);
         order.setTransactionId(transactionId);
         order.setTransactionId(transactionId);
-        order.setOrderNo(orderNo);
-        order.setCreatedAt(now);
         order.setPaidAt(now);
         order.setPaidAt(now);
-        orderMapper.insert(order);
+        orderMapper.updateById(order);
 
 
         subscription.setPaymentId(order.getId());
         subscription.setPaymentId(order.getId());
         subscriptionMapper.updateById(subscription);
         subscriptionMapper.updateById(subscription);
@@ -152,6 +186,39 @@ public class MemberSubscriptionService {
         return subscription;
         return subscription;
     }
     }
 
 
+    /**
+     * 订阅(兼容旧调用/测试模式):createPendingOrder + activateSubscription 组合
+     */
+    public MemberSubscription subscribe(Long familyId, String level, Integer amount,
+                                        String paymentType, String transactionId, String orderNo) {
+        MemberSubscriptionOrder order = createPendingOrder(familyId, level, amount, paymentType);
+        return activateSubscription(familyId, level, amount, paymentType, transactionId, order.getOrderNo());
+    }
+
+    /**
+     * 订阅退款:订单置 refunded + 家庭当前有效订阅置 expired
+     */
+    public void refundByOrderNo(String orderNo) {
+        MemberSubscriptionOrder order = getByOrderNo(orderNo);
+        if (order == null) {
+            log.warn("订阅退款失败,订单不存在: orderNo={}", orderNo);
+            return;
+        }
+        if ("refunded".equals(order.getStatus())) {
+            log.info("订阅退款重复推送,订单已 refunded: orderNo={}", orderNo);
+            return;
+        }
+        order.setStatus("refunded");
+        orderMapper.updateById(order);
+
+        MemberSubscription current = getActiveSubscription(order.getFamilyId());
+        if (current != null) {
+            current.setStatus("expired");
+            current.setUpdatedAt(new Date());
+            subscriptionMapper.updateById(current);
+        }
+    }
+
     /**
     /**
      * 续费订阅
      * 续费订阅
      */
      */

+ 53 - 7
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.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;
+import com.etotem.cfc.entity.MemberSubscriptionOrder;
 import com.etotem.cfc.entity.PaymentOrder;
 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;
@@ -47,6 +48,9 @@ public class VirtualPayService {
     @Resource
     @Resource
     private MembershipService membershipService;
     private MembershipService membershipService;
 
 
+    @Resource
+    private MemberSubscriptionService memberSubscriptionService;
+
     @Value("${wechat.appid}")
     @Value("${wechat.appid}")
     private String appid;
     private String appid;
 
 
@@ -193,9 +197,39 @@ public class VirtualPayService {
         return generatePayParams(config, orderNo, order.getAmount(), config.getGoodsName(), attach, sessionKey);
         return generatePayParams(config, orderNo, order.getAmount(), config.getGoodsName(), attach, sessionKey);
     }
     }
 
 
+    /**
+     * 订阅订单虚拟支付下单参数编排(防循环依赖:编排放本类,MemberSubscriptionService 单向被依赖)。
+     * 订单保持 pending;查不到道具映射返回 null(调用方转 46001 "该商品暂未开放购买")。
+     */
+    public VirtualPayParamsDTO generateSubscriptionPayParams(String orderNo, String sessionKey) {
+        // 1. 查订单 + 校验 pending
+        MemberSubscriptionOrder order = memberSubscriptionService.getByOrderNo(orderNo);
+        if (order == null || !"pending".equals(order.getStatus())) {
+            log.warn("生成订阅支付参数失败,订单不存在或非pending: orderNo={}", orderNo);
+            return null;
+        }
+
+        // 2. 查 virtual_goods_config:goodsType=SUBSCRIPTION, bizKey=订单level → null → 调用方 46001
+        VirtualGoodsConfig config = virtualGoodsConfigService.getEnabled("SUBSCRIPTION", order.getLevel());
+        if (config == null) {
+            log.warn("订阅道具未配置或停用: level={}", order.getLevel());
+            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 = "subscription:" + order.getLevel();
+        return generatePayParams(config, orderNo, order.getAmount(), config.getGoodsName(), attach, sessionKey);
+    }
+
     /**
     /**
      * 处理虚拟支付发货推送(xpay_goods_deliver_notify)。
      * 处理虚拟支付发货推送(xpay_goods_deliver_notify)。
-     * 按 OutTradeNo 前缀路由:A→测评,ORD→会员(TODO Task 7),SUB→订阅(TODO Task 8)。
+     * 按 OutTradeNo 前缀路由:A→测评,ORD→会员,SUB→订阅。
      * 返回微信协议 {@code {"ErrCode":0,"ErrMsg":"success"}}。
      * 返回微信协议 {@code {"ErrCode":0,"ErrMsg":"success"}}。
      */
      */
     public Map<String, Object> handleDeliverNotify(Map<String, Object> payload) {
     public Map<String, Object> handleDeliverNotify(Map<String, Object> payload) {
@@ -223,8 +257,17 @@ public class VirtualPayService {
                 // 会员订单:复用 processPaymentCallback(幂等已内置 L341-343)完成全部开通
                 // 会员订单:复用 processPaymentCallback(幂等已内置 L341-343)完成全部开通
                 membershipService.processPaymentCallback(outTradeNo, transactionId, "virtual");
                 membershipService.processPaymentCallback(outTradeNo, transactionId, "virtual");
             } else if (outTradeNo.startsWith("SUB")) {
             } else if (outTradeNo.startsWith("SUB")) {
-                // TODO Task 8: 订阅解锁,暂未接入,记录日志返回成功
-                log.warn("虚拟支付发货推送暂未接入订阅处理: outTradeNo={}", outTradeNo);
+                // 幂等:已 paid/refunded 的重复推送直接返回成功,避免重复开通/重复佣金结算(spec §6.2)
+                MemberSubscriptionOrder subOrder = memberSubscriptionService.getByOrderNo(outTradeNo);
+                if (subOrder != null && ("paid".equals(subOrder.getStatus()) || "refunded".equals(subOrder.getStatus()))) {
+                    log.info("虚拟支付发货推送重复通知,订阅订单已终态: outTradeNo={}, status={}", outTradeNo, subOrder.getStatus());
+                } else if (subOrder != null) {
+                    // 订阅订单:激活订阅(置旧过期 + 插新订阅 + 订单置paid + 分配管家 + 佣金)
+                    memberSubscriptionService.activateSubscription(subOrder.getFamilyId(), subOrder.getLevel(),
+                            subOrder.getAmount(), "virtual", transactionId, outTradeNo);
+                } else {
+                    log.warn("虚拟支付发货推送订阅订单不存在: outTradeNo={}", outTradeNo);
+                }
             } else {
             } else {
                 log.warn("虚拟支付发货推送未知订单前缀: outTradeNo={}", outTradeNo);
                 log.warn("虚拟支付发货推送未知订单前缀: outTradeNo={}", outTradeNo);
             }
             }
@@ -237,7 +280,7 @@ public class VirtualPayService {
 
 
     /**
     /**
      * 处理虚拟支付退款推送(xpay_refund_notify)。
      * 处理虚拟支付退款推送(xpay_refund_notify)。
-     * 按 OutTradeNo 前缀路由:A→测评退款,ORD/SUB→TODO Task 7/8。
+     * 按 OutTradeNo 前缀路由:A→测评退款,SUB→订阅退款,ORD→TODO。
      */
      */
     public Map<String, Object> handleRefundNotify(Map<String, Object> payload) {
     public Map<String, Object> handleRefundNotify(Map<String, Object> payload) {
         try {
         try {
@@ -252,9 +295,12 @@ public class VirtualPayService {
 
 
             if (outTradeNo.startsWith("A")) {
             if (outTradeNo.startsWith("A")) {
                 assessmentOrderService.refundOrder(outTradeNo);
                 assessmentOrderService.refundOrder(outTradeNo);
-            } else if (outTradeNo.startsWith("ORD") || outTradeNo.startsWith("SUB")) {
-                // TODO Task 7/8: 会员(ORD) / 订阅(SUB) 退款处理,暂未接入
-                log.warn("虚拟支付退款推送暂未接入会员/订阅处理: outTradeNo={}", outTradeNo);
+            } else if (outTradeNo.startsWith("SUB")) {
+                // 订阅退款:订单置 refunded + 订阅置 expired(iOS App Store 退款场景)
+                memberSubscriptionService.refundByOrderNo(outTradeNo);
+            } else if (outTradeNo.startsWith("ORD")) {
+                // TODO 后续: 会员退款处理
+                log.warn("虚拟支付退款推送暂未接入会员处理: outTradeNo={}", outTradeNo);
             } else {
             } else {
                 log.warn("虚拟支付退款推送未知订单前缀: outTradeNo={}", outTradeNo);
                 log.warn("虚拟支付退款推送未知订单前缀: outTradeNo={}", outTradeNo);
             }
             }

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

@@ -20,6 +20,7 @@ import org.mockito.junit.jupiter.MockitoExtension;
 
 
 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.PaymentOrder;
 import com.etotem.cfc.entity.PaymentOrder;
 import com.etotem.cfc.entity.VirtualGoodsConfig;
 import com.etotem.cfc.entity.VirtualGoodsConfig;
 
 
@@ -32,6 +33,9 @@ class VirtualPayServiceTest {
     @Mock
     @Mock
     private MembershipService membershipService;
     private MembershipService membershipService;
 
 
+    @Mock
+    private MemberSubscriptionService memberSubscriptionService;
+
     @Mock
     @Mock
     private VirtualGoodsConfigService virtualGoodsConfigService;
     private VirtualGoodsConfigService virtualGoodsConfigService;
 
 
@@ -190,6 +194,70 @@ class VirtualPayServiceTest {
         assertNull(virtualPayService.generateMembershipPayParams("ORD20260802002", "test_session_key"));
         assertNull(virtualPayService.generateMembershipPayParams("ORD20260802002", "test_session_key"));
     }
     }
 
 
+    @Test
+    void handleDeliverNotify_subscriptionPrefixRoutesToActivateSubscription() {
+        Map<String, Object> payload = new HashMap<>();
+        payload.put("OutTradeNo", "SUB20260802001");
+        payload.put("TransactionId", "WX1234567890");
+
+        MemberSubscriptionOrder order = new MemberSubscriptionOrder();
+        order.setOrderNo("SUB20260802001");
+        order.setFamilyId(1L);
+        order.setLevel("L1");
+        order.setAmount(36500);
+        order.setStatus("pending");
+        when(memberSubscriptionService.getByOrderNo("SUB20260802001")).thenReturn(order);
+
+        Map<String, Object> result = virtualPayService.handleDeliverNotify(payload);
+
+        assertEquals(0, result.get("ErrCode"));
+        assertEquals("success", result.get("ErrMsg"));
+        verify(memberSubscriptionService, times(1))
+                .activateSubscription(1L, "L1", 36500, "virtual", "WX1234567890", "SUB20260802001");
+    }
+
+    @Test
+    void generateSubscriptionPayParams_returnsDtoForConfiguredLevel() throws Exception {
+        setField("appid", "wx5ba8038ef16fb245");
+        setField("appKey", "test_app_key");
+        setField("mchId", "1703255039");
+        setField("env", 0);
+
+        MemberSubscriptionOrder order = new MemberSubscriptionOrder();
+        order.setOrderNo("SUB20260802001");
+        order.setLevel("L1");
+        order.setAmount(36500);
+        order.setStatus("pending");
+        when(memberSubscriptionService.getByOrderNo("SUB20260802001")).thenReturn(order);
+
+        VirtualGoodsConfig config = new VirtualGoodsConfig();
+        config.setProductId("offer_sub_l1");
+        config.setGoodsName("一生一世L1");
+        when(virtualGoodsConfigService.getEnabled("SUBSCRIPTION", "L1")).thenReturn(config);
+
+        VirtualPayParamsDTO dto = virtualPayService.generateSubscriptionPayParams("SUB20260802001", "test_session_key");
+
+        assertNotNull(dto);
+        assertEquals("SUB20260802001", dto.getOrderInfo().get("out_trade_no"));
+        assertEquals(36500, dto.getOrderInfo().get("total_fee"));
+        assertEquals("subscription:L1", dto.getOrderInfo().get("attach"));
+        assertEquals("offer_sub_l1", dto.getOrderInfo().get("offerId"));
+        assertEquals("一生一世L1", dto.getOrderInfo().get("product_info"));
+    }
+
+    @Test
+    void generateSubscriptionPayParams_returnsNullWhenConfigMissing() {
+        MemberSubscriptionOrder order = new MemberSubscriptionOrder();
+        order.setOrderNo("SUB20260802002");
+        order.setLevel("L2");
+        order.setAmount(131400);
+        order.setStatus("pending");
+        when(memberSubscriptionService.getByOrderNo("SUB20260802002")).thenReturn(order);
+        when(virtualGoodsConfigService.getEnabled("SUBSCRIPTION", "L2")).thenReturn(null);
+
+        assertNull(virtualPayService.generateSubscriptionPayParams("SUB20260802002", "test_session_key"));
+    }
+
     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);