# 会员费虚拟支付开关设计
**日期:** 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