Pārlūkot izejas kodu

docs: 会员费虚拟支付开关实现计划

iwt 3 nedēļas atpakaļ
vecāks
revīzija
636f900899

+ 786 - 0
docs/superpowers/plans/2026-08-29-membership-pay-switch.md

@@ -0,0 +1,786 @@
+# 会员费虚拟支付开关实现计划
+
+> **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。
+
+**目标:** 在会员费支付链路增加 `member_pay_virtual_enabled` 配置开关,通过后端 `payType` 字段分流到虚拟支付(`requestVirtualPayment`)或普通微信支付(`wx.requestPayment`),并打通 v3 JSAPI 回调到会员开通。
+
+**架构:** 后端在 `MembershipService.createOrderWithPayType()` 内读 `sys_config` 开关,返回带 `payType` 字段的统一响应;前端 `pay.vue` 根据 `payType` 分支调用对应支付 SDK;普通支付 v3 回调通过 `PaymentService.handleWechatNotify()` ORD 前缀路由到 `processPaymentCallback()`。
+
+**技术栈:** Java 8 + Spring Boot 2.7.18 + MyBatis-Plus + 微信支付 v3 API(JSAPI)+ uni-app Vue 2
+
+---
+
+## 文件结构
+
+| 文件 | 变更类型 | 职责 |
+|------|---------|------|
+| `cfc-backend/.../service/MembershipService.java` | 修改 | 新增 `createOrderWithPayType()`;`createOrder()` 拆单逻辑增加 `splitEnabled` 控制 |
+| `cfc-backend/.../controller/MembershipController.java` | 修改 | `createOrder()` 接收 `openid`,调用新方法 |
+| `cfc-backend/.../service/PaymentService.java` | 修改 | `handleWechatNotify()` 补充 ORD 前缀路由;注入 `MembershipService`(`@Lazy`) |
+| `cfc-backend/src/test/java/.../service/MembershipPaySwitchTest.java` | 创建 | 单元测试覆盖开关分流逻辑 |
+| `cfc-frontend/utils/api.js` | 修改 | `createOrder()` 透传 `openid` 参数 |
+| `cfc-frontend/pages/membership/pay.vue` | 修改 | 按 `payType` 分支,新增 `payWechat()` 方法 |
+
+---
+
+## 任务 1:MembershipService 新增 createOrderWithPayType()
+
+**文件:**
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/service/MembershipService.java`
+- 测试:`cfc-backend/src/test/java/com/etotem/cfc/service/MembershipPaySwitchTest.java`
+
+**前置知识:**
+- `sysConfigService.getValue("member_pay_virtual_enabled")` 返回 null 或 "1" 表示开启虚拟支付;返回 "0" 表示关闭
+- `virtualPayService.generateMembershipPayParams(orderNo, sessionKey)` 生成虚拟支付参数,返回 null 表示道具未配置
+- `paymentService.createWechatPrepay(orderNo, description, amount, openid)` 生成 v3 JSAPI 预付参数
+- 拆单逻辑在 `createOrder()` L369-L384,基于 `virtual_goods_config.goods_price` 判断——关闭虚拟支付时应跳过(`splitEnabled=false`)
+- `createOrder()` 的 3 个调用方(L190/210/231)均走虚拟支付,不受影响
+
+**步骤 1.1:在 MembershipService 中注入 PaymentService**
+
+在 `MembershipService.java` 现有的 `@Resource` 块(L27 起)追加:
+
+```java
+@Resource
+private PaymentService paymentService;
+```
+
+**步骤 1.2:为 createOrder() 拆单逻辑增加 splitEnabled 控制**
+
+在 `MembershipService.java` L295 找到 `createOrder()` 方法,找到 L369 行:
+
+```java
+// 修改前:
+if (!"trial".equals(paymentType)) {
+// 修改后:
+if (!"trial".equals(paymentType)) {
+```
+
+替换为:
+
+```java
+// 在 createOrder() 方法开头(L295 之后)增加 splitEnabled=true 默认值
+// 原 createOrder() 签名不变(保持向后兼容);拆单条件改为始终执行(默认拆单开启)
+if (!"trial".equals(paymentType)) {
+```
+
+> **说明**:`createOrder()` 现有调用方均为虚拟支付路径,拆单行为不变。新建的 `createOrderWithPayType()` 不走 `createOrder()`,而是直接内联订单创建逻辑,避免拆单。
+
+**步骤 1.3:新增 createOrderWithPayType() 方法(内联订单创建)**
+
+在 `MembershipService.java` 中 `createOrder()` 方法之后新增。该方法完整复制 `createOrder()` 的订单创建逻辑,但增加 `splitEnabled` 参数(关闭时跳过 L369-L384 的拆单判断):
+
+```java
+/**
+ * 创建会员订单并按 member_pay_virtual_enabled 开关决定支付方式。
+ *
+ * @param sessionKey 当前用户 sessionKey(虚拟支付签名用;普通支付时忽略)
+ * @param openid     普通支付时需要(JSAPI payer.openid);虚拟支付时可为 null
+ * @return 统一响应 map,含 payType("virtual"/"wechat"/"none")及对应字段;
+ *         虚拟支付道具未配置时返回 null(调用方转 46001)
+ */
+public Map<String, Object> createOrderWithPayType(Long userId, Long familyId, String levelCode,
+        String paymentType, String period, Long userCouponId, String sessionKey, String openid) {
+
+    // === 内联订单创建(复制 createOrder() 核心逻辑,splitEnabled 由开关控制)===
+    if (familyId == null) {
+        throw new RuntimeException("请先创建或加入家庭后再购买会员");
+    }
+    MembershipLevel level = levelMapper.selectOne(
+            new LambdaQueryWrapper<MembershipLevel>()
+                    .eq(MembershipLevel::getLevelCode, levelCode)
+                    .last("LIMIT 1"));
+    if (level == null) {
+        throw new RuntimeException("会员等级不存在");
+    }
+
+    // 根据 period 确定费用(分)— 与 createOrder() 逻辑完全一致
+    Integer amount;
+    String effectivePeriod = period != null ? period : "yearly";
+    if ("FAMILY".equals(levelCode)) {
+        effectivePeriod = "yearly";
+        String feeStr = sysConfigService.getValue("member_fee_family_yearly");
+        if (feeStr == null) { feeStr = sysConfigService.getValue("member_fee_family"); }
+        amount = feeStr != null ? Integer.parseInt(feeStr) : 131400;
+    } else if ("PROVIDER".equals(levelCode)) {
+        String configKey = "member_fee_standard_" + effectivePeriod;
+        String feeStr = sysConfigService.getValue(configKey);
+        if (feeStr == null) { feeStr = sysConfigService.getValue("member_fee_standard"); }
+        amount = feeStr != null ? Integer.parseInt(feeStr) : 1314;
+    } else if ("PREMIUM".equals(levelCode)) {
+        if ("monthly".equals(effectivePeriod)) {
+            String feeStr = sysConfigService.getValue("member_fee_premium_monthly");
+            amount = feeStr != null ? Integer.parseInt(feeStr) : 131400;
+        } else {
+            String feeStr = sysConfigService.getValue("member_fee_premium");
+            amount = feeStr != null ? Integer.parseInt(feeStr) : 1316800;
+        }
+    } else {
+        if ("monthly".equals(effectivePeriod)) {
+            amount = level.getPriceMonthly();
+        } else if ("quarterly".equals(effectivePeriod)) {
+            amount = level.getPriceQuarterly() != null ? level.getPriceQuarterly() : level.getPriceMonthly() * 3;
+        } else {
+            amount = level.getPriceYearly();
+        }
+    }
+
+    int originalAmount = amount;
+    int finalAmount = originalAmount;
+    Long appliedCouponId = null;
+
+    if (!"trial".equals(paymentType)) {
+        if (userCouponId != null) {
+            Integer discount = couponService.apply(userId, userCouponId, "MEMBERSHIP", originalAmount);
+            if (discount != null && discount > 0) {
+                finalAmount = Math.max(0, originalAmount - discount);
+                appliedCouponId = userCouponId;
+            }
+        }
+    } else {
+        finalAmount = 0;
+    }
+
+    // 开关判定:缺省/null/"1"=虚拟支付(拆单开启);"0"=普通支付(拆单关闭)
+    boolean virtualEnabled = !"0".equals(sysConfigService.getValue("member_pay_virtual_enabled"));
+
+    // 拆单:查 MEMBERSHIP 道具配置的 goods_price,按整倍数判断(virtualEnabled=false 时跳过)
+    String groupNo = null;
+    int totalPeriods = 1;
+    Integer splitPeriodAmount = null;
+    if (!"trial".equals(paymentType) && virtualEnabled) {
+        VirtualGoodsConfig goodsConfig = virtualGoodsConfigService.getEnabled("MEMBERSHIP", levelCode);
+        Integer goodsPrice = goodsConfig != null ? goodsConfig.getGoodsPrice() : null;
+        if (goodsPrice != null && goodsPrice > 0) {
+            if (finalAmount % goodsPrice != 0) {
+                throw new RuntimeException("支付金额必须是道具价格的整数倍,无法支付");
+            }
+            totalPeriods = finalAmount / goodsPrice;
+            if (totalPeriods > 1) {
+                splitPeriodAmount = goodsPrice;
+                groupNo = OrderNoGenerator.generate("G");
+            } else {
+                splitPeriodAmount = goodsPrice;
+            }
+        }
+    }
+
+    // 创建订单
+    PaymentOrder mainOrder = null;
+    List<PaymentOrder> allOrders = new ArrayList<>();
+    if (groupNo != null) {
+        for (int i = 1; i <= totalPeriods; i++) {
+            PaymentOrder sub = new PaymentOrder();
+            sub.setOrderNo(generateOrderNo());
+            sub.setFamilyId(familyId);
+            sub.setLevelCode(levelCode);
+            sub.setPaymentType(paymentType);
+            sub.setPeriod(effectivePeriod);
+            sub.setAmount(splitPeriodAmount);
+            sub.setUserCouponId(i == 1 ? appliedCouponId : null);
+            sub.setStatus("pending");
+            sub.setGroupNo(groupNo);
+            sub.setPeriodNo(i);
+            sub.setCreatedAt(new Date());
+            sub.setUpdatedAt(new Date());
+            paymentOrderMapper.insert(sub);
+            allOrders.add(sub);
+            if (i == 1) { mainOrder = sub; }
+        }
+    } else {
+        PaymentOrder order = new PaymentOrder();
+        order.setOrderNo(generateOrderNo());
+        order.setFamilyId(familyId);
+        order.setLevelCode(levelCode);
+        order.setPaymentType(paymentType);
+        order.setPeriod(effectivePeriod);
+        order.setAmount(finalAmount);
+        order.setUserCouponId(appliedCouponId);
+        order.setStatus("trial".equals(paymentType) ? "paid" : "pending");
+        order.setPeriodNo(1);
+        order.setCreatedAt(new Date());
+        order.setUpdatedAt(new Date());
+        paymentOrderMapper.insert(order);
+        allOrders.add(order);
+        mainOrder = order;
+    }
+
+    if ("trial".equals(paymentType)) {
+        activateTrialMembership(userId, familyId, mainOrder);
+    }
+
+    // === 构造响应 ===
+    Map<String, Object> resp = new HashMap<>();
+    resp.put("orderNo", mainOrder.getOrderNo());
+    resp.put("status", mainOrder.getStatus());
+    resp.put("levelCode", mainOrder.getLevelCode());
+    resp.put("amount", mainOrder.getAmount());
+    resp.put("groupNo", mainOrder.getGroupNo());
+    resp.put("periodNo", mainOrder.getPeriodNo());
+    resp.put("totalPeriods", totalPeriods);
+
+    // trial 单已直接开通,无需支付
+    if ("trial".equals(paymentType)) {
+        resp.put("payType", "none");
+        return resp;
+    }
+
+    if (virtualEnabled) {
+        VirtualPayParamsDTO dto = virtualPayService.generateMembershipPayParams(mainOrder.getOrderNo(), sessionKey);
+        if (dto == null) {
+            return null; // 道具未配置,调用方转 46001
+        }
+        resp.put("payType", "virtual");
+        resp.put("orderInfo", dto.getOrderInfo());
+        resp.put("extInfo", dto.getExtInfo());
+        resp.put("sign", dto.getSign());
+        resp.put("signature", dto.getSignature());
+        resp.put("env", dto.getEnv());
+        resp.put("signType", dto.getSignType());
+        resp.put("currentPeriod", dto.getCurrentPeriod());
+        return resp;
+    }
+
+    // 普通微信支付(v3 JSAPI)
+    String description = "浠艾福-" + levelCode + "会员";
+    Map<String, Object> payParams = paymentService.createWechatPrepay(
+            mainOrder.getOrderNo(), description, mainOrder.getAmount(), openid);
+    resp.put("payType", "wechat");
+    resp.put("wechatPayParams", payParams);
+    return resp;
+}
+```
+
+**步骤 1.4:编写单元测试**
+
+创建 `cfc-backend/src/test/java/com/etotem/cfc/service/MembershipPaySwitchTest.java`:
+
+```java
+package com.etotem.cfc.service;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import com.etotem.cfc.dto.PaymentOrderDTO;
+import com.etotem.cfc.dto.VirtualPayParamsDTO;
+import com.etotem.cfc.entity.MembershipLevel;
+import com.etotem.cfc.entity.PaymentOrder;
+import com.etotem.cfc.mapper.*;
+import com.etotem.cfc.entity.Family;
+import com.etotem.cfc.entity.User;
+
+@ExtendWith(MockitoExtension.class)
+class MembershipPaySwitchTest {
+
+    @InjectMocks
+    private MembershipService membershipService;
+
+    @Mock private MembershipLevelMapper levelMapper;
+    @Mock private FamilyMembershipMapper membershipMapper;
+    @Mock private FamilyMapper familyMapper;
+    @Mock private UserMapper userMapper;
+    @Mock private PaymentOrderMapper paymentOrderMapper;
+    @Mock private CommissionService commissionService;
+    @Mock private SysConfigService sysConfigService;
+    @Mock private MemberUpgradeRecordMapper memberUpgradeRecordMapper;
+    @Mock private MembershipBenefitsMapper benefitsMapper;
+    @Mock private CouponService couponService;
+    @Mock private TrialMembershipMapper trialMembershipMapper;
+    @Mock private VirtualGoodsConfigService virtualGoodsConfigService;
+    @Mock private VirtualPayService virtualPayService;
+    @Mock private PaymentService paymentService;
+
+    // --- 虚拟支付开启(默认) ---
+
+    @Test
+    void createOrderWithPayType_virtualEnabled_returnsVirtualPayType() {
+        when(sysConfigService.getValue("member_pay_virtual_enabled")).thenReturn("1");
+        when(sysConfigService.getValue("member_fee_family")).thenReturn("131400");
+        when(levelMapper.selectOne(any())).thenReturn(buildLevel("FAMILY", 131400, null, null));
+        when(paymentOrderMapper.selectCount(any())).thenReturn(0L);
+        when(couponService.apply(anyLong(), anyLong(), anyString(), anyInt())).thenReturn(null);
+        when(paymentOrderMapper.insert(any())).thenReturn(1);
+
+        VirtualPayParamsDTO mockDto = new VirtualPayParamsDTO();
+        mockDto.setOrderInfo(new HashMap<>());
+        mockDto.setSign("mock-sign");
+        mockDto.setSignature("mock-sig");
+        when(virtualPayService.generateMembershipPayParams(anyString(), anyString())).thenReturn(mockDto);
+
+        User user = new User();
+        user.setFamilyId(1L);
+        when(userMapper.selectById(1L)).thenReturn(user);
+
+        Map<String, Object> result = membershipService.createOrderWithPayType(
+                1L, 1L, "FAMILY", "pay", "yearly", null, "mockSessionKey", null);
+
+        assertNotNull(result);
+        assertEquals("virtual", result.get("payType"));
+        assertEquals("ORD", result.get("orderNo") != null ? result.get("orderNo").toString().substring(0, 3) : "");
+        assertNotNull(result.get("orderInfo"));
+        assertNotNull(result.get("sign"));
+    }
+
+    // --- 虚拟支付关闭 ---
+
+    @Test
+    void createOrderWithPayType_virtualDisabled_returnsWechatPayType() {
+        when(sysConfigService.getValue("member_pay_virtual_enabled")).thenReturn("0");
+        when(sysConfigService.getValue("member_fee_family")).thenReturn("131400");
+        when(levelMapper.selectOne(any())).thenReturn(buildLevel("FAMILY", 131400, null, null));
+        when(paymentOrderMapper.selectCount(any())).thenReturn(0L);
+        when(couponService.apply(anyLong(), anyLong(), anyString(), anyInt())).thenReturn(null);
+        when(paymentOrderMapper.insert(any())).thenReturn(1);
+
+        Map<String, Object> mockWechatParams = new HashMap<>();
+        mockWechatParams.put("appId", "wx5ba8038ef16fb245");
+        mockWechatParams.put("timeStamp", "1724900000");
+        mockWechatParams.put("package", "prepay_id=wx_mock");
+        when(paymentService.createWechatPrepay(anyString(), anyString(), anyInt(), anyString()))
+                .thenReturn(mockWechatParams);
+
+        User user = new User();
+        user.setFamilyId(1L);
+        when(userMapper.selectById(1L)).thenReturn(user);
+
+        Map<String, Object> result = membershipService.createOrderWithPayType(
+                1L, 1L, "FAMILY", "pay", "yearly", null, "mockSessionKey", "mockOpenid");
+
+        assertNotNull(result);
+        assertEquals("wechat", result.get("payType"));
+        assertNotNull(result.get("wechatPayParams"));
+    }
+
+    @Test
+    void createOrderWithPayType_virtualDisabled_noSplit() {
+        when(sysConfigService.getValue("member_pay_virtual_enabled")).thenReturn("0");
+        when(sysConfigService.getValue("member_fee_family")).thenReturn("131400");
+        when(levelMapper.selectOne(any())).thenReturn(buildLevel("FAMILY", 131400, null, null));
+        when(paymentOrderMapper.selectCount(any())).thenReturn(0L);
+        when(couponService.apply(anyLong(), anyLong(), anyString(), anyInt())).thenReturn(null);
+        when(paymentOrderMapper.insert(any())).thenReturn(1);
+
+        Map<String, Object> mockWechatParams = new HashMap<>();
+        mockWechatParams.put("package", "prepay_id=wx_mock");
+        when(paymentService.createWechatPrepay(anyString(), anyString(), anyInt(), anyString()))
+                .thenReturn(mockWechatParams);
+
+        User user = new User();
+        user.setFamilyId(1L);
+        when(userMapper.selectById(1L)).thenReturn(user);
+
+        Map<String, Object> result = membershipService.createOrderWithPayType(
+                1L, 1L, "FAMILY", "pay", "yearly", null, "mockSessionKey", "mockOpenid");
+
+        assertNotNull(result);
+        assertEquals("wechat", result.get("payType"));
+        assertNull(result.get("groupNo"));
+        assertEquals(1, result.get("totalPeriods"));
+    }
+
+    // --- trial 路径 ---
+
+    @Test
+    void createOrderWithPayType_trial_returnsNone() {
+        when(sysConfigService.getValue("member_pay_virtual_enabled")).thenReturn("1");
+        when(sysConfigService.getValue("member_fee_family")).thenReturn("131400");
+        when(levelMapper.selectOne(any())).thenReturn(buildLevel("FAMILY", 131400, null, null));
+        when(paymentOrderMapper.selectCount(any())).thenReturn(0L);
+        when(paymentOrderMapper.insert(any())).thenReturn(1);
+
+        User user = new User();
+        user.setFamilyId(1L);
+        when(userMapper.selectById(1L)).thenReturn(user);
+
+        Family family = new Family();
+        family.setId(1L);
+        when(familyMapper.selectById(1L)).thenReturn(family);
+
+        Map<String, Object> result = membershipService.createOrderWithPayType(
+                1L, 1L, "FAMILY", "trial", null, null, "mockSessionKey", null);
+
+        assertNotNull(result);
+        assertEquals("none", result.get("payType"));
+    }
+
+    // --- 道具未配置 ---
+
+    @Test
+    void createOrderWithPayType_virtualEnabled_configMissing_returnsNull() {
+        when(sysConfigService.getValue("member_pay_virtual_enabled")).thenReturn("1");
+        when(sysConfigService.getValue("member_fee_family")).thenReturn("131400");
+        when(levelMapper.selectOne(any())).thenReturn(buildLevel("FAMILY", 131400, null, null));
+        when(paymentOrderMapper.selectCount(any())).thenReturn(0L);
+        when(couponService.apply(anyLong(), anyLong(), anyString(), anyInt())).thenReturn(null);
+        when(paymentOrderMapper.insert(any())).thenReturn(1);
+        when(virtualPayService.generateMembershipPayParams(anyString(), anyString())).thenReturn(null);
+
+        User user = new User();
+        user.setFamilyId(1L);
+        when(userMapper.selectById(1L)).thenReturn(user);
+
+        Map<String, Object> result = membershipService.createOrderWithPayType(
+                1L, 1L, "FAMILY", "pay", "yearly", null, "mockSessionKey", null);
+
+        assertNull(result);
+    }
+
+    // --- 辅助方法 ---
+
+    private MembershipLevel buildLevel(String code, int yearly, Integer monthly, Integer quarterly) {
+        MembershipLevel level = new MembershipLevel();
+        level.setLevelCode(code);
+        level.setPriceYearly(yearly);
+        level.setPriceMonthly(monthly);
+        level.setPriceQuarterly(quarterly);
+        return level;
+    }
+}
+```
+
+**步骤 1.5:编译验证**
+
+```bash
+cd /sc-data/cfc/cfc-backend && mvn clean compile
+```
+预期:`BUILD SUCCESS`
+
+**步骤 1.6:运行单元测试**
+
+```bash
+cd /sc-data/cfc/cfc-backend && mvn test -Dtest=MembershipPaySwitchTest -DfailIfNoTests=false
+```
+预期:4 tests passed,0 failures
+
+**步骤 1.7:Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/service/MembershipService.java \
+        cfc-backend/src/test/java/com/etotem/cfc/service/MembershipPaySwitchTest.java
+git commit -m "feat(membership): 新增 createOrderWithPayType 开关分流虚拟/普通支付"
+```
+
+---
+
+## 任务 2:MembershipController 改造
+
+**文件:**
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/controller/MembershipController.java`
+
+**步骤 2.1:改造 createOrder() 方法**
+
+将 `MembershipController.java` 中 `/orders` 端点(L217-L254)替换为:
+
+```java
+/**
+ * 创建支付订单
+ */
+@Operation(summary = "创建订单")
+@PostMapping("/orders")
+public Result<Object> createOrder(@RequestBody Map<String, Object> params,
+    @RequestAttribute("userId") Long userId) {
+    if (userId == null) {
+        return Result.error("用户未登录");
+    }
+
+    User user = userMapper.selectById(userId);
+    if (user == null) {
+        return Result.error("用户不存在");
+    }
+
+    Long familyId = membershipService.getUserFamilyId(userId);
+    String levelCode = params.get("levelCode").toString();
+    String paymentType = params.get("paymentType").toString();
+    String period = params.get("period") != null ? params.get("period").toString() : "yearly";
+    Long couponId = ParamUtils.getLong(params.get("couponId"));
+    String openid = params.get("openid") != null ? params.get("openid").toString() : null;
+
+    // trial 单:createOrder 内已直接开通(status=paid),无需支付,原样返回
+    if ("trial".equals(paymentType)) {
+        PaymentOrderDTO order = membershipService.createOrder(userId, familyId, levelCode, paymentType, period, couponId);
+        return Result.success(order);
+    }
+
+    // testMode:保持现有模拟逻辑(不触发真实虚拟/普通支付)
+    if (testMode) {
+        PaymentOrderDTO order = membershipService.createOrder(userId, familyId, levelCode, paymentType, period, couponId);
+        return Result.success(order);
+    }
+
+    // 调用新方法,按开关分流
+    Map<String, Object> result = membershipService.createOrderWithPayType(
+            userId, familyId, levelCode, paymentType, period, couponId,
+            user.getSessionKey(), openid);
+
+    if (result == null) {
+        return Result.error(46001, "该商品暂未开放购买");
+    }
+    return Result.success(result);
+}
+```
+
+**步骤 2.2:确认 testMode 字段存在**
+
+`MembershipController.java` L42 已有:
+```java
+@Value("${wechat.test-mode:false}")
+private boolean testMode;
+```
+无需修改。
+
+**步骤 2.3:编译验证**
+
+```bash
+cd /sc-data/cfc/cfc-backend && mvn clean compile
+```
+预期:`BUILD SUCCESS`
+
+**步骤 2.4:Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/controller/MembershipController.java
+git commit -m "feat(membership): Controller 改为调用 createOrderWithPayType,支持 openid 参数"
+```
+
+---
+
+## 任务 3:PaymentService ORD 回调路由
+
+**文件:**
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/service/PaymentService.java`
+
+**步骤 3.1:在 PaymentService 中注入 MembershipService(@Lazy 防循环)**
+
+在 `PaymentService.java` 的 `@Resource` 块中追加:
+
+```java
+@Lazy
+@Resource
+private MembershipService membershipService;
+```
+
+**步骤 3.2:修改 handleWechatNotify() 的 ORD 前缀路由**
+
+在 `PaymentService.java` 找到 `handleWechatNotify()` 方法中的路由逻辑(L329-334):
+
+```java
+// 修改前:
+if (orderNo != null && orderNo.startsWith("PO")) {
+    productOrderService.handlePaymentSuccess(orderNo, transactionId);
+} else {
+    handlePaymentCallback(orderNo, transactionId, "wechat");
+}
+```
+
+替换为:
+
+```java
+// 修改后:ORD→会员开通,PO→商城,其余→PackageOrder(原有 fallback)
+if (orderNo != null && orderNo.startsWith("ORD")) {
+    membershipService.processPaymentCallback(orderNo, transactionId, "wechat");
+} else if (orderNo != null && orderNo.startsWith("PO")) {
+    productOrderService.handlePaymentSuccess(orderNo, transactionId);
+} else {
+    handlePaymentCallback(orderNo, transactionId, "wechat");
+}
+```
+
+**步骤 3.3:编译验证**
+
+```bash
+cd /sc-data/cfc/cfc-backend && mvn clean compile
+```
+预期:`BUILD SUCCESS`(`@Lazy` 解决循环依赖)
+
+**步骤 3.4:Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/service/PaymentService.java
+git commit -m "feat(pay): handleWechatNotify 增加 ORD 前缀路由到会员开通回调"
+```
+
+---
+
+## 任务 4:前端 — api.js 和 pay.vue
+
+**文件:**
+- 修改:`cfc-frontend/utils/api.js`
+- 修改:`cfc-frontend/pages/membership/pay.vue`
+
+**步骤 4.1:修改 api.js 中 createOrder() 透传 openid**
+
+在 `cfc-frontend/utils/api.js` 中找到 `createOrder` 函数(L330):
+
+```js
+// 修改前:
+export const createOrder = (levelCode, paymentType, period, couponId) => {
+  var data = { levelCode: levelCode, paymentType: paymentType }
+  if (period) data.period = period
+  if (couponId) data.couponId = couponId
+  return request('/api/membership/orders', 'POST', data)
+}
+
+// 修改后:
+export const createOrder = (levelCode, paymentType, period, couponId, openid) => {
+  var data = { levelCode: levelCode, paymentType: paymentType }
+  if (period) data.period = period
+  if (couponId) data.couponId = couponId
+  if (openid) data.openid = openid
+  return request('/api/membership/orders', 'POST', data)
+}
+```
+
+**步骤 4.2:修改 pay.vue handlePay() 支持 payType 分流**
+
+在 `cfc-frontend/pages/membership/pay.vue` 的 `handlePay` 方法(L217)中,将现有的 if-else 分支替换为:
+
+```js
+handlePay: function() {
+  var self = this
+  if (this.paying) return
+  this.paying = true
+  var couponId = this.selectedCoupon ? this.selectedCoupon.id : null
+  var openid = uni.getStorageSync('openid') || ''
+  createOrder(this.levelCode, 'pay', this.period, couponId, openid).then(function(res) {
+    var data = res.data || {}
+    self.orderNo = data.orderNo
+    var payType = data.payType || 'virtual'
+
+    if (payType === 'virtual' && data.orderInfo && data.sign) {
+      // 虚拟支付(现有逻辑不变)
+      self.totalPeriods = data.totalPeriods || 1
+      self.currentPeriod = data.currentPeriod || 1
+      self.splitAmount = data.orderInfo.goodsPrice || (self.finalPrice / (self.totalPeriods || 1))
+      self.payInstallments(data)
+    } else if (payType === 'wechat' && data.wechatPayParams) {
+      // 普通微信支付(新增分支)
+      self.payWechat(data.wechatPayParams)
+    } else {
+      // testMode/trial:模拟支付成功
+      self.orderNo = data.orderNo
+      uni.showToast({ title: '支付成功,开通中…', icon: 'none' })
+      setTimeout(function() {
+        self.paying = false
+        uni.redirectTo({ url: '/pages/membership/result?orderNo=' + (self.orderNo || '') + '&status=success' })
+      }, 800)
+    }
+  }).catch(function(e) {
+    self.paying = false
+    if (e.code === 46001) {
+      uni.showToast({ title: '该商品暂未开放购买', icon: 'none' })
+    } else {
+      uni.showToast({ title: e.message || '下单失败', icon: 'none' })
+    }
+  })
+},
+```
+
+**步骤 4.3:新增 payWechat() 方法**
+
+在 `pay.vue` 的 `methods` 中,`finishPay` 方法之前插入:
+
+```js
+payWechat: function(pp) {
+  var self = this
+  if (!pp || !pp.package) {
+    self.paying = false
+    uni.showToast({ title: '支付参数异常', icon: 'none' })
+    return
+  }
+  uni.requestPayment({
+    provider: 'wxpay',
+    timeStamp: pp.timeStamp,
+    nonceStr: pp.nonceStr,
+    package: pp.package,
+    signType: pp.signType || 'RSA',
+    paySign: pp.paySign || pp.sign,
+    success: function() {
+      uni.showToast({ title: '支付成功,开通中…', icon: 'none' })
+      setTimeout(function() {
+        self.paying = false
+        uni.redirectTo({ url: '/pages/membership/result?orderNo=' + (self.orderNo || '') + '&status=success' })
+      }, 800)
+    },
+    fail: function(err) {
+      self.paying = false
+      var errCode = err && err.errCode
+      var msg = '支付取消'
+      if (errCode === -15007 || errCode === -15005) msg = '登录态已过期,请重新登录'
+      else if (err && err.errMsg) msg = err.errMsg
+      uni.showToast({ title: msg, icon: 'none' })
+      setTimeout(function() {
+        uni.redirectTo({ url: '/pages/membership/result?orderNo=' + (self.orderNo || '') + '&status=fail' })
+      }, 800)
+    }
+  })
+}
+```
+
+**步骤 4.4:语法校验**
+
+```bash
+# api.js 语法检查
+node --check /sc-data/cfc/cfc-frontend/utils/api.js && echo "api.js OK"
+
+# pay.vue script 块语法检查
+sed -n '/<script>/,/<\/script>/p' /sc-data/cfc/cfc-frontend/pages/membership/pay.vue | sed '1d;$d' > /tmp/pay-check.js
+node --check /tmp/pay-check.js && echo "pay.vue OK"
+```
+预期:两行均输出 `OK`
+
+**步骤 4.5:Commit**
+
+```bash
+git add cfc-frontend/utils/api.js cfc-frontend/pages/membership/pay.vue
+git commit -m "feat(frontend): pay.vue 支持 payType 分流,新增普通微信支付路径"
+```
+
+---
+
+## 任务 5:全量编译验证
+
+**步骤 5.1:后端编译**
+
+```bash
+cd /sc-data/cfc/cfc-backend && mvn clean compile
+```
+预期:`BUILD SUCCESS`
+
+**步骤 5.2:路由重复检查**
+
+```bash
+grep -rn '@Mapping' /sc-data/cfc/cfc-backend/src/main/java/com/etotem/cfc/controller/ | grep -oP '@\w+Mapping\("\K[^"]*' | sort -u
+```
+预期:无 `/api/membership/orders` 重复路径
+
+**步骤 5.3:Bean 命名冲突检查**
+
+```bash
+# 确认 PaymentService 和 MembershipService 无循环注入问题
+cd /sc-data/cfc/cfc-backend && mvn compile -q 2>&1 | grep -i "circular\|cyclic\|bean.*conflict" || echo "no conflicts"
+```
+预期:`no conflicts`
+
+---
+
+## 验收标准对照
+
+- [ ] `member_pay_virtual_enabled=1`(或省略)→ 接口返回 `payType=virtual`,含 `orderInfo/sign/signature`
+- [ ] `member_pay_virtual_enabled=0` → 接口返回 `payType=wechat`,含 `wechatPayParams`
+- [ ] 关闭虚拟支付时订单无 `groupNo`,`totalPeriods=1`(单订单全额支付)
+- [ ] v3 回调中 ORD 前缀 → `MembershipService.processPaymentCallback()`(幂等,含补开通逻辑)
+- [ ] trial 路径(`paymentType=trial`)返回 `payType=none`,行为不变
+- [ ] testMode 下返回订单信息(模拟支付),行为不变
+- [ ] 虚拟支付道具未配置 → 返回 null → Controller 转 46001
+- [ ] `mvn clean compile` 通过
+- [ ] 前端脚本语法检查通过