2026-08-29-membership-pay-switch.md 30 KB

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

面向 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 起)追加:

@Resource
private PaymentService paymentService;

步骤 1.2:为 createOrder() 拆单逻辑增加 splitEnabled 控制

在 MembershipService.java L295 找到 createOrder() 方法,找到 L369 行:

// 修改前:
if (!"trial".equals(paymentType)) {
// 修改后:
if (!"trial".equals(paymentType)) {

替换为:

// 在 createOrder() 方法开头(L295 之后)增加 splitEnabled=true 默认值
// 原 createOrder() 签名不变(保持向后兼容);拆单条件改为始终执行(默认拆单开启)
if (!"trial".equals(paymentType)) {

说明:createOrder() 现有调用方均为虚拟支付路径,拆单行为不变。新建的 createOrderWithPayType() 不走 createOrder(),而是直接内联订单创建逻辑,避免拆单。

步骤 1.3:新增 createOrderWithPayType() 方法(内联订单创建)

在 MembershipService.java 中 createOrder() 方法之后新增。该方法完整复制 createOrder() 的订单创建逻辑,但增加 splitEnabled 参数(关闭时跳过 L369-L384 的拆单判断):

/**
 * 创建会员订单并按 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:

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:编译验证

cd /sc-data/cfc/cfc-backend && mvn clean compile

预期:BUILD SUCCESS

步骤 1.6:运行单元测试

cd /sc-data/cfc/cfc-backend && mvn test -Dtest=MembershipPaySwitchTest -DfailIfNoTests=false

预期:4 tests passed,0 failures

步骤 1.7:Commit

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)替换为:

/**
 * 创建支付订单
 */
@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 已有:

@Value("${wechat.test-mode:false}")
private boolean testMode;

无需修改。

步骤 2.3:编译验证

cd /sc-data/cfc/cfc-backend && mvn clean compile

预期:BUILD SUCCESS

步骤 2.4:Commit

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 块中追加:

@Lazy
@Resource
private MembershipService membershipService;

步骤 3.2:修改 handleWechatNotify() 的 ORD 前缀路由

在 PaymentService.java 找到 handleWechatNotify() 方法中的路由逻辑(L329-334):

// 修改前:
if (orderNo != null && orderNo.startsWith("PO")) {
    productOrderService.handlePaymentSuccess(orderNo, transactionId);
} else {
    handlePaymentCallback(orderNo, transactionId, "wechat");
}

替换为:

// 修改后: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:编译验证

cd /sc-data/cfc/cfc-backend && mvn clean compile

预期:BUILD SUCCESS(@Lazy 解决循环依赖)

步骤 3.4:Commit

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):

// 修改前:
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 分支替换为:

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 方法之前插入:

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:语法校验

# 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

git add cfc-frontend/utils/api.js cfc-frontend/pages/membership/pay.vue
git commit -m "feat(frontend): pay.vue 支持 payType 分流,新增普通微信支付路径"

任务 5:全量编译验证

步骤 5.1:后端编译

cd /sc-data/cfc/cfc-backend && mvn clean compile

预期:BUILD SUCCESS

步骤 5.2:路由重复检查

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 命名冲突检查

# 确认 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 通过
  • 前端脚本语法检查通过