日期: 2026-08-02 状态: 设计稿 v1 优先级: P0(合规要求) 范围: 测评订单 + 会员 + 订阅(道具直购模式)
微信官方《虚拟支付业务运营指南》:小程序内提供的虚拟商品(虚拟代币、解锁功能、订阅内容、付费功能、打赏、虚拟礼物等),购买和支付均需接入小程序虚拟支付,不得引导至 app、公众号、H5、个人号、网站完成支付。该能力已支持全终端(Android/鸿蒙/Windows → 微信支付,iOS → Apple 支付)。
本项目现状(勘察结论):
wx.requestVirtualPayment / xpay 调用assessment_orders(测评订单,前缀 A):假支付——payOrder() 直接标记已付,无预付/无回调payment_orders(会员,前缀 ORD):手动回调,无真实支付member_subscription_order(L1/L2 订阅,前缀 SUB):假定外部已支付uni.requestPayment(合规,本次不动)wechat.appkey(虚拟支付 paySig 签名必需)配置缺失豁免说明: 线上问诊/法律咨询属豁免范畴,本项目不涉及。
| 决策项 | 结论 |
|---|---|
| 接入范围 | 测评订单 + 会员 + 订阅(商城虚拟商品、任务模板包本期不做) |
| 虚拟支付模式 | 道具直购(每个服务对应一个 ProductId,支付即解锁) |
| 道具映射存储 | DB 配置表 virtual_goods_config + Web 管理端 CRUD |
| 微信侧状态 | 虚拟支付已开通、小程序简称已配置、可联调 |
| 支付金额 | 均 ≥1 元(满足 iOS 最低 1 元要求,无 0 元旁路) |
| 道具配置状态 | 尚未在微信后台建道具 → 开发不阻塞,映射表先建,上线前配置 |
┌───────────────────────────── cfc-frontend (uni-app) ─────────────────────────────┐
│ 测评购买页 / 会员升级页 / 订阅页 │
│ │ ① 创建待支付订单 (业务下单接口,status=pending) │
│ ▼ │
│ 后端下单接口 ──► 生成虚拟支付参数 {orderInfo, extInfo, sign, env} │
│ │ ② 前端 wx.requestVirtualPayment(orderInfo, extInfo, sign) │
│ ▼ ③ 用户支付成功(success 回调可能丢失) │
│ 微信客户端 ──► 平台路由: Android/鸿蒙/Windows→微信支付 iOS→Apple 支付 │
└─────────────────────────────┴────────────────────────────────────────────────────┘
│ ④ xpay_goods_deliver_notify 发货推送(消息推送机制)
▼
┌───────────────────────────── cfc-backend ────────────────────────────────────────┐
│ [新] VirtualPayController POST /api/internal/virtual-pay/notify ← 接收推送 │
│ [新] VirtualPayService paySig签名 / 发货推送处理 / 发货通知 / 订单查询 │
│ [新] VirtualGoodsConfig 道具映射表(DB + Web管理端) │
│ │ ⑤ 按订单前缀路由: A→测评解锁 ORD→会员开通 SUB→订阅开通 │
│ ▼ │
│ AssessmentOrderService / MembershipService / MemberSubscriptionService │
│ │ ⑥ 兜底: notify_provide_goods 通知发货完成 │
└───────────────────────────────────────────────────────────────────────────────────┘
新增组件清单:
| 组件 | 职责 | 位置 |
|---|---|---|
VirtualPayService |
paySig 签名计算、发货推送处理、notify_provide_goods、query_order |
cfc-backend/.../service/VirtualPayService.java |
VirtualPayController |
接收 xpay 消息推送(公开端点) | cfc-backend/.../controller/VirtualPayController.java |
VirtualGoodsConfig |
道具映射实体 + Mapper | cfc-backend/.../entity/ + mapper/ |
virtual_goods_config 表 |
业务SKU↔ProductId 映射 | schema.sql + DatabaseInitializer |
VirtualPayParamsDTO |
支付参数返回体 | cfc-backend/.../dto/ |
| Web 管理页 | 道具映射 CRUD | cfc-web/src/views/ + api/admin.js |
| 前端改造 | 测评/会员/订阅支付调用改为虚拟支付 | payment.vue、测评/会员/订阅购买页、utils/api.js |
不改动:现有 v3 微信支付(PaymentService / ProductOrderService / payment.vue 实物支付分支)——实物商品继续走原链路。
virtual_goods_configCREATE TABLE virtual_goods_config (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
goods_type VARCHAR(32) NOT NULL COMMENT 'ASSESSMENT_PACKAGE / MEMBERSHIP / SUBSCRIPTION',
biz_key VARCHAR(64) NOT NULL COMMENT '业务SKU标识: 测评套餐ID / 会员等级code / 订阅档位code',
product_id VARCHAR(64) NOT NULL COMMENT '微信商户后台道具ID',
goods_name VARCHAR(128) NOT NULL COMMENT '道具名称',
status TINYINT NOT NULL DEFAULT 1 COMMENT '1=启用 0=停用',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_goods_type_biz (goods_type, biz_key)
) COMMENT '小程序虚拟支付道具映射';
(goods_type, biz_key)pay() 接口查不到启用映射 → 返回错误码 46001(道具未配置),前端提示"该商品暂未开放购买"DatabaseInitializer.runMigrations() 新增建表迁移(幂等),同步 schema.sqlWeb 管理端: 新增 VirtualGoodsConfig.vue(admin 菜单"虚拟支付道具"),支持按 goods_type 筛选 + CRUD + 启用/停用。
// paySig = to_hex(hmac_sha256(appKey, uri + "&" + signData))
// uri 固定为 "requestVirtualPayment"(wx.requestVirtualPayment 场景)
// signData = orderInfo + extInfo 按官方规则拼接(实现时对照官方 Python 脚本输出做断言单测)
String paySig = HmacUtils.hmacSha256Hex(appKey, "requestVirtualPayment&" + signData);
VirtualPayParamsDTO{
"orderInfo": {
"mch_id": "虚拟支付商户号",
"appid": "wx5ba8038ef16fb245",
"out_trade_no": "A20260802xxx",
"total_fee": 9900,
"product_info": "五维测评·基础版",
"attach": "assessment:12"
},
"extInfo": {},
"sign": "paySig值",
"env": 0,
"signType": "HMAC-SHA256"
}
total_fee 单位:分,以服务端计算为准(防篡改)attach 透传业务信息(goodsType:bizKey),便于对账env:0=现网 1=沙箱,由配置 wechat.virtual-pay-env 控制| 业务 | 现有接口 | 改造方式 |
|---|---|---|
| 测评 | POST /api/dan-assessment/order/pay |
AssessmentOrderService.payOrder() 不再直接标记已付,改为生成 VirtualPayParams 返回(订单保持 pending) |
| 会员 | POST /api/membership/orders |
MembershipService.createOrder() 后新增 pay 流程:生成 VirtualPayParams 返回 |
| 订阅 | POST /api/subscription/create(JSON body 下单)/ /auto-renew |
MemberSubscriptionService 改为生成 VirtualPayParams 返回(不再假定已支付) |
统一行为: 下单接口先校验 virtual_goods_config 映射存在且启用;校验金额 ≥ 1 元;返回支付参数后订单状态保持 pending,只有收到发货推送才置 paid。
POST /api/internal/virtual-pay/notify(挂 JwtInterceptor 已有公开前缀 /api/internal/,无需新增豁免)cfc.bianwoyou.com.cn)。若后台要求加密模式,配置 EncodingAESKey 并实现加解密;设计默认明文 + 格式校验,联调时按后台实际配置确认。| Event | 处理 | 响应 |
|---|---|---|
xpay_goods_deliver_notify |
按 OutTradeNo 前缀路由:A→测评 ORD→会员 SUB→订阅;标记已付 + 解锁权益(测评确认预约/会员插入 FamilyMembership/订阅开通),记录 transaction_id、paid_at |
{"ErrCode":0,"ErrMsg":"success"} |
xpay_refund_notify |
标记退款状态(iOS 用户 App Store 退款同样走此推送) | {"ErrCode":0,"ErrMsg":"success"} |
xpay_coin_pay_notify / 其他 |
记日志忽略(道具直购不涉及) | {"ErrCode":0,"ErrMsg":"success"} |
幂等: 按 OutTradeNo 查订单,已 paid 的重复推送直接返回成功。微信推送最多重试 15 次(间隔 2/4/8/16...秒)。
notify_provide_goods(POST /xpay/notify_provide_goods)仅在推送确认异常时由对账任务触发| 场景 | 处理 |
|---|---|
| Android/微信支付退款 | 业务退款接口改造:调 xpay/refund_order 启动退款 → 轮询 query_order 确认 → xpay_refund_notify 更新状态 |
| iOS/Apple 退款 | 开发者不能主动退款。用户 App Store 申请 → 平台推 xpay_subscribe_ios_refund_query_notify 问询(3 秒内应答;3 次未应答默认"不确定"交 Apple 裁决)→ 退款成功走 xpay_refund_notify。需实现问询应答端点 POST /api/internal/virtual-pay/ios-refund-query |
| success 回调丢失 | 前端支付结果页轮询后端订单状态(沿用现有轮询模式)+ 后端定时任务调 query_order 兜底(扩展 OrderTimeoutCancelTask 思路) |
| 道具未配置 | 返回 46001,前端提示不可购买 |
| 金额篡改 | total_fee 服务端重新计算校验 |
application.yml wechat: 块新增:
wechat:
appkey: ${WECHAT_APPKEY:} # 虚拟支付 AppKey(商户后台基本配置获取,现网/沙箱分开)
virtual-pay-env: ${WECHAT_VIRTUAL_PAY_ENV:0} # 0=现网 1=沙箱
同步更新 config/application.yml(模板)与 config/application-prod.yml(生产覆盖)。
utils/api.js)新增 requestVirtualPayment(params) 封装,内部调 wx.requestVirtualPayment,与现有 uni.requestPayment 分支共存:
// 判断虚拟支付可用性(iOS 需 8.0.68+ / iOS 15+,Android 需基础库 2.19.2+)
wx.requestVirtualPayment({
orderInfo: params.orderInfo,
extInfo: params.extInfo,
success(res) { /* 仅提示,最终以服务端订单状态为准 */ },
fail(err) { /* 用户取消/失败,回订单页 */ }
})
| 页面 | 改动 |
|---|---|
pages/assessment/*(测评购买/申请页) |
下单 → 拿 VirtualPayParams → requestVirtualPayment;支付结果页轮询订单状态 |
pages/membership/upgrade.vue |
同样流程替换"直接开通成功"假逻辑 |
pages/membership/plans.vue(订阅) |
同样流程 |
pages/shop/payment/payment.vue |
不动(实物商品 v3 路径保留);若未来商城虚拟商品接入,在此加分支 |
VirtualPayService 签名算法对照官方 Python 脚本固定输出断言(文档示例 c37809f27c...)wechat.test-mode: true 下 mock 直接标记已付,跳过虚拟支付(保留现有模拟逻辑)OutTradeNo 只处理一次mvn clean compile;路由重复检查:grep -rn '@Mapping' ... | grep -oP '@\w+Mapping\("\K[^"]*' | sort -urefund_order;iOS 问询应答端点可响应;退款推送更新订单状态virtual_goods_config 表迁移幂等可重复执行;schema.sql 已同步mvn clean compile 通过;无路由重复;无 Bean 命名冲突| 项 | 说明 |
|---|---|
| 微信后台道具配置 | 上线前必须在商户后台建道具并发布至现网,映射表才能生效(开发不阻塞) |
| 消息推送 URL 配置 | 需 mp.weixin.qq.com 配置服务器推送 URL;加密模式需联调确认 |
| iOS 真机验证 | 沙箱不支持 iOS,需真机现网验证(最低 1 元、iOS 15+、微信 8.0.68+) |
| 服务费率 | iOS 端 17%(2026 年腾讯技术服务费限时减免 → 实际 12%),结算周期 Apple 月结 45-60 天 |
| 平台处罚 | 不接入虚拟支付的虚拟商品将被平台管控,本改造即合规响应 |
product_orders 非实物,如测评额度商品)→ 接入同一 VirtualPayService 链路package_orders)