# 会员费虚拟支付开关设计 **日期:** 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` ```java /** * 创建会员订单并按后台开关决定支付方式。 *

开关 member_pay_virtual_enabled:1/缺省=虚拟支付;0=普通微信支付(JSAPI)。 * @param sessionKey 当前用户 sessionKey(用于虚拟支付签名;普通支付时忽略) * @param openid 普通支付时需要(JSAPI payer.openid);虚拟支付时可为 null */ public Map 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 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 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` ```java @PostMapping("/orders") public Result createOrder(@RequestBody Map 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 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): ```java if (orderNo != null && orderNo.startsWith("PO")) { productOrderService.handlePaymentSuccess(orderNo, transactionId); } else { handlePaymentCallback(orderNo, transactionId, "wechat"); // 走 package_orders } ``` 改造:`ORD` 前缀 → `MembershipService.processPaymentCallback`: ```java 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 ```js 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 分流 ```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 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 字段) ```json { "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 普通微信支付开启时(新增) ```json { "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 回调,另行评估)