|
|
@@ -0,0 +1,396 @@
|
|
|
+# 会员费虚拟支付开关设计
|
|
|
+
|
|
|
+**日期:** 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
|
|
|
+/**
|
|
|
+ * 创建会员订单并按后台开关决定支付方式。
|
|
|
+ * <p>开关 member_pay_virtual_enabled:1/缺省=虚拟支付;0=普通微信支付(JSAPI)。
|
|
|
+ * @param openid 普通支付时需要(JSAPI payer.openid);虚拟支付时可为 null
|
|
|
+ */
|
|
|
+public Map<String, Object> createOrderWithPayType(Long userId, Long familyId, String levelCode,
|
|
|
+ String paymentType, String period, Long userCouponId, 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);
|
|
|
+ // 注:sessionKey 由 controller 传入(Controller 持 user,VirtualPayService 不读库)
|
|
|
+ // —— 具体签名:本方法加参 String sessionKey,controller 传 user.getSessionKey()
|
|
|
+ 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`
|
|
|
+
|
|
|
+```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):
|
|
|
+```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 回调,另行评估)
|