2026-08-29-membership-pay-switch-design.md 18 KB

会员费虚拟支付开关设计

日期: 2026-08-29 状态: 设计稿 v1 优先级: P1 范围: 会员费支付方式可切换(虚拟支付 ↔ 普通微信支付)


1. 需求与背景

用户故事

As 平台管理员, I want 在后台通过配置开关控制会员费走虚拟支付还是普通微信支付, So that 在虚拟支付联调/上线过渡期可灵活切换,无需改代码重新发版。

现状(勘察结论)

  • POST /api/membership/orders(MembershipController.createOrder())目前强制走虚拟支付:
    1. createOrder() 创建订单(payment_orders,前缀 ORD,status=pending)
    2. 非 testMode 时调用 virtualPayService.generateMembershipPayParams() 生成 VirtualPayParamsDTO(含 orderInfo/sign/signature)
    3. 前端 pay.vue 收到 orderInfo → wx.requestVirtualPayment
  • 会员无普通微信支付(v3 JSAPI)路径。现有 JSAPI 能力:
    • PaymentService.createWechatPrepay(orderNo, description, totalFee, openid) — 通用 v3 JSAPI 预付单(已用于 PackagePaymentService 的套餐支付)
    • PaymentService.handleWechatNotify() — v3 回调已按订单前缀路由,但 ORD 前缀未接入
    • 前端 payment.vue(商城)已有 wechatPayParams + wx.requestPayment 分支先例
  • sys_config 表(SysConfigService)已提供 key-value 配置能力,后端 admin 角色可经 POST /api/admin/config/update 修改——后台开关可直接复用,无需新表

已确认决策

决策项 结论
开关范围 全局开关(所有会员等级同时生效)
开关机制 sys_config 表 member_pay_virtual_enabled(1=虚拟支付,0=普通微信支付;缺省=1 保持现状)
前端口径 payType 分流:后端在响应中明确 payType=virtual/wechat,前端分支调用 requestVirtualPayment / wx.requestPayment
普通支付 WeChat v3 JSAPI(wx.requestPayment),复用 PaymentService.createWechatPrepay

2. 整体架构

┌───────────────────── cfc-frontend pay.vue ─────────────────────┐
│  handlePay()                                                    │
│    ① createOrder(levelCode, 'pay', period, couponId, openid)   │
│        │  ② 后端返回统一响应体: { payType, orderXXX, ... }      │
│        ▼                                                       │
│    payType == 'virtual'? ──是──► requestVirtualPayment(dto)     │
│        │ 否                                                    │
│        ▼                                                       │
│    wx.requestPayment(wechatPayParams)  ③ 拉起微信支付           │
└────────────┬─────────────────────────────────────────────────────┘
             ▼  微信支付回调(notify_url 指向后端)
┌───────────────────── cfc-backend ──────────────────────────────┐
│  PaymentService.handleWechatNotify()(v3 回调已按前缀路由)      │
│    ④ ORD 前缀 → MembershipService.processPaymentCallback()      │
│        (幂等已内置,与虚拟支付发货推送同一入口)                  │
└─────────────────────────────────────────────────────────────────┘

3. 配置开关

sys_config key:member_pay_virtual_enabled

值 行为
1(或不存在,缺省) 虚拟支付:返回 VirtualPayParamsDTO(现状不变)
0 普通微信支付:返回订单信息 + wechatPayParams
  • 读取方式:SysConfigService.getValue("member_pay_virtual_enabled"),解析 "0" 为关闭,其余(含 null)视为开启
  • 后台操作路径(已存在,零新增):
    • 查看:POST /api/admin/config/all / /list
    • 修改:POST /api/admin/config/update(body: {configKey, configValue, description})
    • Web 管理端:SysConfig.xml 页面已有编辑入口(如无该菜单项则手动补一行即可,不属本次范围)
  • 迁移要求:无。sys_config 表已存在;缺省 null 即虚拟支付,向后兼容

4. 后端变更

4.1 MembershipService — 新增 createOrderWithPayType()

文件: cfc-backend/src/main/java/com/etotem/cfc/service/MembershipService.java

/**
 * 创建会员订单并按后台开关决定支付方式。
 * <p>开关 member_pay_virtual_enabled:1/缺省=虚拟支付;0=普通微信支付(JSAPI)。
 * @param sessionKey  当前用户 sessionKey(用于虚拟支付签名;普通支付时忽略)
 * @param openid      普通支付时需要(JSAPI payer.openid);虚拟支付时可为 null
 */
public Map<String, Object> createOrderWithPayType(Long userId, Long familyId, String levelCode,
        String paymentType, String period, Long userCouponId, String sessionKey, String openid) {

    // 1. 创建订单(现状逻辑不变,trial 直接开通返回)
    PaymentOrderDTO order = createOrder(userId, familyId, levelCode, paymentType, period, userCouponId);

    // 类型统一返回(避免 controller 判断 trial/testMode)
    Map<String, Object> resp = new HashMap<>();
    resp.put("orderNo", order.getOrderNo());
    resp.put("status", order.getStatus());
    resp.put("levelCode", order.getLevelCode());
    resp.put("amount", order.getAmount());
    resp.put("groupNo", order.getGroupNo());
    resp.put("periodNo", order.getPeriodNo());
    resp.put("totalPeriods", order.getTotalPeriods());

        // 2. trial 单已直接开通,无需支付
        if ("trial".equals(paymentType)) {
            resp.put("payType", "none");
            return resp;
        }

        // 3. 判定支付方式:开关缺省=虚拟支付
        boolean virtualEnabled = !"0".equals(sysConfigService.getValue("member_pay_virtual_enabled"));

        if (virtualEnabled) {
            VirtualPayParamsDTO dto = virtualPayService.generateMembershipPayParams(order.getOrderNo(), sessionKey);
        if (dto == null) {
            // 道具未配置 → 沿用现状 46001(由 controller 转 error)
        }
        resp.put("payType", "virtual");
        resp.putAll(dto 字段);   // orderInfo/sign/signature/env/signType/currentPeriod/totalPeriods
        return resp;
    }

    // 4. 普通微信支付:创建 v3 JSAPI 预付单
    String description = "浠艾福-" + levelCode + "会员";
    Map<String, Object> payParams = paymentService.createWechatPrepay(
            order.getOrderNo(), description, order.getAmount(), openid);
    resp.put("payType", "wechat");
    resp.put("wechatPayParams", payParams);
    return resp;
}

⚠️ 实现注意:

  • MembershipService 需注入 PaymentService(@Resource,字段名 paymentService)——两者无循环依赖(PaymentService 不依赖 MembershipService)
  • createOrder() 内部已有拆单逻辑(groupNo/periodNo),普通支付模式不支持拆单(微信 JSAPI 单订单金额可任意,道具拆单逻辑仅虚拟支付需要)。即:开关关闭时,createOrder() 中基于 virtual_goods_config.goods_price 的拆单判断应跳过。拆单判断在 createOrder 内部通过查 MEMBERSHIP 道具配置触发(L370-L384),需用参数控制或按开关跳过:
    • 方案:给 createOrder() 加 boolean splitEnabled 参数或开关读取 → 关闭虚拟支付时不查道具、不拆单
    • 简化:createOrder() 内部本来就是在 goodsConfig != null 时才拆——关闭虚拟支付时业务上可正常跳过(金额不要求是道具价整数倍),但为稳妥,按开关显式跳过拆单

4.2 MembershipController.createOrder() 改造

文件: cfc-backend/src/main/java/com/etotem/cfc/controller/MembershipController.java

@PostMapping("/orders")
public Result<Object> createOrder(@RequestBody Map<String, Object> params,
        @RequestAttribute("userId") Long userId) {
    // ... 现有参数解析不变
    String openid = (String) params.get("openid");   // 新增:普通支付 JSAPI 需要

    // trial 单:直接返回(createOrder 内已开通)
    // testMode 单:返回订单信息(现状兼容)
    //       ↑ 注:testMode 分支保留在 controller 或下沉 service——下沉后 testMode 返回 payType='wechat' 会调微信 mock,也可接受
    
    User user = userMapper.selectById(userId);
    if (user == null || user.getSessionKey() == null || user.getSessionKey().isEmpty()) {
        return Result.error(400, "会话失效,请重新登录");
    }
    
    Map<String, Object> result = membershipService.createOrderWithPayType(
            userId, familyId, levelCode, paymentType, period, couponId, user.getSessionKey(), openid);
    
    if ("virtual".equals(result.get("payType"))
            && (result.get("orderInfo") == null || result.get("sign") == null)) {
        return Result.error(46001, "该商品暂未开放购买");   // 道具未配置
    }
    return Result.success(result);
}

Controller 调整点汇总:

变化 说明
请求体新增 openid(可选) 普通支付时需要;虚拟支付/试用不含也兼容
返回体新增 payType virtual / wechat / none(trial)
testMode 分支 保留测试模式捷径:不在 service 内调微信(testMode 时 PaymentService.doPost 返回 mock 预付单——可接受,前端仍走 JSAPI 拉起,微信端 mock 失败属预期测试行为)
46001 错误码 仅虚拟支付时道具缺失返回

4.3 PaymentService.handleWechatNotify() — ORD 前缀接入会员回调

文件: cfc-backend/src/main/java/com/etotem/cfc/service/PaymentService.java

现状(L329-334):

if (orderNo != null && orderNo.startsWith("PO")) {
    productOrderService.handlePaymentSuccess(orderNo, transactionId);
} else {
    handlePaymentCallback(orderNo, transactionId, "wechat");   // 走 package_orders
}

改造:ORD 前缀 → MembershipService.processPaymentCallback:

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");
}

⚠️ PaymentService 注入 MembershipService:PaymentService 当前不依赖 MembershipService,无循环依赖风险。 注意 processPaymentCallback 内部已有幂等(status=paid 短路 + 补开通逻辑),拆单组全部付清后自动开通——与虚拟支付发货推送同一入口,行为一致。


5. 前端变更

5.1 utils/api.js — createOrder() 增加 openid

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

5.2 pages/membership/pay.vue — payType 分流

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

    if (data.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)
      self.payInstallments(data)
    } else if (data.payType === 'wechat' && data.wechatPayParams) {
      // 普通微信支付(新增)
      self.payWechat(data.wechatPayParams)
    } else {
      // testMode/trial:模拟成功(现状逻辑不变)
      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' })
    }
  })
},

// 普通微信支付(新增方法,复用商城 payment.vue 的分支模式)
payWechat: function(pp) {
  var self = this
  uni.requestPayment({
    provider: 'wxpay',
    timeStamp: pp.timeStamp,
    nonceStr: pp.nonceStr,
    package: pp.package,
    signType: pp.signType || 'RSA',
    paySign: pp.paySign || pp.sign,
    success: function() {
      // 后端回调异步开通,跳结果页轮询(沿用 membership/result 现状)
      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
      uni.redirectTo({ url: '/pages/membership/result?orderNo=' + (self.orderNo || '') + '&status=fail' })
    }
  })
}

⚠️ 小程序限制遵守:不用可选链、不用 :key 表达式、Vue2 Options API。


6. API 响应体(新)

6.1 虚拟支付开启时(现状不变 + payType 字段)

{
  "code": 200,
  "message": "success",
  "data": {
    "payType": "virtual",
    "orderNo": "ORD20260829xxx",
    "status": "pending",
    "levelCode": "FAMILY",
    "amount": 131400,
    "totalPeriods": 1,
    "currentPeriod": 1,
    "orderInfo": { "offerId": "...", "productId": "member1314", "goodsPrice": 131400, "outTradeNo": "ORD..." , "attach": "membership:FAMILY"},
    "extInfo": {},
    "sign": "...",
    "signature": "...",
    "env": 0,
    "signType": "HMAC-SHA256"
  }
}

6.2 普通微信支付开启时(新增)

{
  "code": 200,
  "message": "success",
  "data": {
    "payType": "wechat",
    "orderNo": "ORD20260829xxx",
    "status": "pending",
    "levelCode": "FAMILY",
    "amount": 131400,
    "totalPeriods": 1,
    "periodNo": 1,
    "wechatPayParams": {
      "appId": "wx5ba8038ef16fb245",
      "timeStamp": "1724900000",
      "nonceStr": "abc123",
      "package": "prepay_id=wx...",
      "signType": "RSA",
      "paySign": "..."
    }
  }
}

7. 边界与异常处理

场景 行为
开关=0 且未传 openid JSAPI 预付单创建失败(微信报错),返回 500——可接受:前端正常流程必带 openid(登录时已存 storage)
开关=0 且支付成功回调 PaymentService.handleWechatNotify → ORD 前缀 → processPaymentCallback(幂等)
拆单 普通支付模式不拆单(createOrder 按开关跳过道具金额拆单逻辑),单次支付全额
开关在支付中途切换 订单按创建时的 payType 走(支付参数已生成),不影响已生成支付参数;下次下单按新开关
优惠券 两种模式均走 createOrder 现有券逻辑,行为一致
testMode 保留现状捷径:直接返回订单信息,前端模拟成功
道具缺配(虚拟支付) 保留 46001「该商品暂未开放购买」

8. 测试策略

  1. 单测(新增 MembershipPaySwitchTest 或并入现有):
    • 开关=1/缺省 → payType=virtual 且含 orderInfo/sign
    • 开关=0 → payType=wechat 且含 wechatPayParams;createWechatPrepay 被调用(mock)
    • trial 单 → payType=none
    • 开关=0 时拆单被跳过(groupNo=null、单订单、金额=全价)
  2. 回调路由单测:handleWechatNotify ORD 前缀 → processPaymentCallback;重复回调幂等
  3. 回归:开关缺省时行为与现状完全一致(虚拟支付路径零改动)
  4. 编译:mvn clean compile;路由重复检查 grep -rn '@Mapping' ... | sort -u
  5. 前端:node --check 语法校验 pay.vue 的 script 块(不打包)

9. 验收标准

  • sys_config 添加 member_pay_virtual_enabled=0 后,会员下单返回 payType=wechat 且含 wechatPayParams,前端拉起普通微信支付
  • 开关恢复 1 后,会员下单返回 payType=virtual(现状行为不变)
  • 普通支付成功 → v3 回调 → ORD 前缀路由 → 会员开通(FamilyMembership + 用户升级 + 佣金),与虚拟支付等价
  • 普通支付拆单行为:单订单一次支付全额,无 groupNo
  • trial / testMode 路径不受影响
  • 46001 道具未配置行为在虚拟支付模式下保留
  • mvn clean compile 通过;无路由重复

10. 未覆盖范围(后续迭代)

  • 测评订单 / 订阅订单的支付开关(本次仅会员;如需要按同一模式扩展)
  • Web 管理端开关 UI 美化(当前直接用 sys_config 编辑入口即可)
  • 普通支付的退款链路(会员退款现有 refundByOrderNo 基于虚拟支付推送触发;普通支付退款需再接 v3 refund 回调,另行评估)