日期: 2026-08-29 状态: 设计稿 v1 优先级: P1 范围: 会员费支付方式可切换(虚拟支付 ↔ 普通微信支付)
As 平台管理员, I want 在后台通过配置开关控制会员费走虚拟支付还是普通微信支付, So that 在虚拟支付联调/上线过渡期可灵活切换,无需改代码重新发版。
POST /api/membership/orders(MembershipController.createOrder())目前强制走虚拟支付:
createOrder() 创建订单(payment_orders,前缀 ORD,status=pending)virtualPayService.generateMembershipPayParams() 生成 VirtualPayParamsDTO(含 orderInfo/sign/signature)pay.vue 收到 orderInfo → wx.requestVirtualPaymentPaymentService.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 |
┌───────────────────── 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() │
│ (幂等已内置,与虚拟支付发货推送同一入口) │
└─────────────────────────────────────────────────────────────────┘
sys_config key:member_pay_virtual_enabled| 值 | 行为 |
|---|---|
1(或不存在,缺省) |
虚拟支付:返回 VirtualPayParamsDTO(现状不变) |
0 |
普通微信支付:返回订单信息 + wechatPayParams |
SysConfigService.getValue("member_pay_virtual_enabled"),解析 "0" 为关闭,其余(含 null)视为开启POST /api/admin/config/all / /listPOST /api/admin/config/update(body: {configKey, configValue, description})SysConfig.xml 页面已有编辑入口(如无该菜单项则手动补一行即可,不属本次范围)sys_config 表已存在;缺省 null 即虚拟支付,向后兼容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时才拆——关闭虚拟支付时业务上可正常跳过(金额不要求是道具价整数倍),但为稳妥,按开关显式跳过拆单
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 错误码 | 仅虚拟支付时道具缺失返回 |
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 短路 + 补开通逻辑),拆单组全部付清后自动开通——与虚拟支付发货推送同一入口,行为一致。
utils/api.js — createOrder() 增加 openidexport 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)
}
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。
{
"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"
}
}
{
"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": "..."
}
}
}
| 场景 | 行为 |
|---|---|
| 开关=0 且未传 openid | JSAPI 预付单创建失败(微信报错),返回 500——可接受:前端正常流程必带 openid(登录时已存 storage) |
| 开关=0 且支付成功回调 | PaymentService.handleWechatNotify → ORD 前缀 → processPaymentCallback(幂等) |
| 拆单 | 普通支付模式不拆单(createOrder 按开关跳过道具金额拆单逻辑),单次支付全额 |
| 开关在支付中途切换 | 订单按创建时的 payType 走(支付参数已生成),不影响已生成支付参数;下次下单按新开关 |
| 优惠券 | 两种模式均走 createOrder 现有券逻辑,行为一致 |
| testMode | 保留现状捷径:直接返回订单信息,前端模拟成功 |
| 道具缺配(虚拟支付) | 保留 46001「该商品暂未开放购买」 |
MembershipPaySwitchTest 或并入现有):
payType=virtual 且含 orderInfo/signpayType=wechat 且含 wechatPayParams;createWechatPrepay 被调用(mock)trial 单 → payType=nonegroupNo=null、单订单、金额=全价)handleWechatNotify ORD 前缀 → processPaymentCallback;重复回调幂等mvn clean compile;路由重复检查 grep -rn '@Mapping' ... | sort -unode --check 语法校验 pay.vue 的 script 块(不打包)sys_config 添加 member_pay_virtual_enabled=0 后,会员下单返回 payType=wechat 且含 wechatPayParams,前端拉起普通微信支付1 后,会员下单返回 payType=virtual(现状行为不变)mvn clean compile 通过;无路由重复sys_config 编辑入口即可)refundByOrderNo 基于虚拟支付推送触发;普通支付退款需再接 v3 refund 回调,另行评估)