# 小程序虚拟支付接入设计 **日期:** 2026-08-02 **状态:** 设计稿 v1 **优先级:** P0(合规要求) **范围:** 测评订单 + 会员 + 订阅(道具直购模式) --- ## 1. 背景与合规要求 微信官方《虚拟支付业务运营指南》:小程序内提供的**虚拟商品**(虚拟代币、解锁功能、订阅内容、付费功能、打赏、虚拟礼物等),购买和支付**均需接入小程序虚拟支付**,不得引导至 app、公众号、H5、个人号、网站完成支付。该能力已支持全终端(Android/鸿蒙/Windows → 微信支付,iOS → Apple 支付)。 **本项目现状(勘察结论):** - 全项目无任何 `wx.requestVirtualPayment` / `xpay` 调用 - 5 套订单体系中有 3 套不合规: - `assessment_orders`(测评订单,前缀 `A`):**假支付**——`payOrder()` 直接标记已付,无预付/无回调 - `payment_orders`(会员,前缀 `ORD`):手动回调,无真实支付 - `member_subscription_order`(L1/L2 订阅,前缀 `SUB`):假定外部已支付 - 商城实物商品走 v3 JSAPI `uni.requestPayment`(合规,本次不动) - 项目**无**微信消息推送(message push)接收端点——虚拟支付发货推送需从零建 - `wechat.appkey`(虚拟支付 paySig 签名必需)配置缺失 **豁免说明:** 线上问诊/法律咨询属豁免范畴,本项目不涉及。 ## 2. 已确认决策 | 决策项 | 结论 | |--------|------| | 接入范围 | 测评订单 + 会员 + 订阅(商城虚拟商品、任务模板包本期不做) | | 虚拟支付模式 | **道具直购**(每个服务对应一个 ProductId,支付即解锁) | | 道具映射存储 | **DB 配置表 `virtual_goods_config` + Web 管理端 CRUD** | | 微信侧状态 | 虚拟支付已开通、小程序简称已配置、可联调 | | 支付金额 | 均 ≥1 元(满足 iOS 最低 1 元要求,无 0 元旁路) | | 道具配置状态 | 尚未在微信后台建道具 → 开发不阻塞,映射表先建,上线前配置 | ## 3. 总体架构 ``` ┌───────────────────────────── 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` 实物支付分支)——实物商品继续走原链路。 ## 4. 道具映射表 `virtual_goods_config` ```sql CREATE 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.sql` **Web 管理端:** 新增 `VirtualGoodsConfig.vue`(admin 菜单"虚拟支付道具"),支持按 goods_type 筛选 + CRUD + 启用/停用。 ## 5. 支付签名与下单 ### 5.1 签名算法(官方参考脚本同款) ```java // paySig = to_hex(hmac_sha256(appKey, "requestVirtualPayment&" + signData)) // signature = to_hex(hmac_sha256(sessionKey, signData)) // sessionKey 直接字符串编码,不 base64 解码 // signData = orderInfo 的 JSON 串(LinkedHashMap 插入序序列化,与前端 JSON.stringify(orderInfo) 逐字节一致) String paySig = HmacUtils.hmacSha256Hex(appKey, "requestVirtualPayment&" + signData); ``` > ⚠️ 修正(2026-08-03):signData 必须是 **JSON 串**(非字典序 key=value 拼接),且 paySig/signature 都基于**同一** JSON 串(前端实际发送的 signData),否则微信验签报 -15005。 ### 5.2 后端下单返回体 `VirtualPayParamsDTO` ```json { "orderInfo": { "offerId": "1450607643", "buyQuantity": 1, "env": 0, "currencyType": "CNY", "productId": "member1314", "goodsPrice": 131400, "outTradeNo": "A20260802xxx", "attach": "assessment:12" }, "extInfo": {}, "sign": "paySig值", "signature": "用户身份签名", "env": 0, "signType": "HMAC-SHA256" } ``` - **orderInfo 为官方 `wx.requestVirtualPayment` signData 固定结构(camelCase),字段集不得增删:** - `offerId`:**支付应用ID**(mp-虚拟支付基本配置,全应用唯一)——**不是道具ID** - `productId`:**道具ID**(如 member1314),仅 `mode=short_series_goods` 必填 - `goodsPrice`:道具单价(分),官方校验与后台道具价格一致(金额以服务端计算为准,防篡改) - `buyQuantity`:道具直购恒为 1 - `attach`:透传业务信息(goodsType:bizKey),发货推送时透传,便于对账 - `mode`:顶层参数(前端传 `short_series_goods`),**不在 signData 内** - ⚠️ 前端 `signData` 必须 = `JSON.stringify(orderInfo)`(保持插入序),与后端签名串逐字节一致,否则 -15005 - `env`:0=现网 1=沙箱,由配置 `wechat.virtual-pay-env` 控制 ### 5.3 三个业务下单接口改造 | 业务 | 现有接口 | 改造方式 | |------|----------|----------| | 测评 | `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`**。 ## 6. 发货推送处理(结果确认核心) ### 6.1 接收端点 - **路径:** `POST /api/internal/virtual-pay/notify`(挂 `JwtInterceptor` 已有公开前缀 `/api/internal/`,无需新增豁免) - **需在 mp.weixin.qq.com 配置:** 服务器推送 URL 指向该端点(生产域名 `cfc.bianwoyou.com.cn`)。若后台要求加密模式,配置 `EncodingAESKey` 并实现加解密;**设计默认明文 + 格式校验**,联调时按后台实际配置确认。 ### 6.2 推送类型处理 | 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...秒)。 ### 6.3 发货完成通知 - 正常路径:发货推送返回成功即视为发货完成,无需额外调用 - 兜底:`notify_provide_goods`(`POST /xpay/notify_provide_goods`)仅在推送确认异常时由对账任务触发 ## 7. 退款与异常处理 | 场景 | 处理 | |------|------| | **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 服务端重新计算校验 | ## 8. 配置变更 `application.yml` `wechat:` 块新增: ```yaml wechat: appkey: ${WECHAT_APPKEY:} # 虚拟支付 AppKey(商户后台基本配置获取,现网/沙箱分开) sandbox-appkey: ${WECHAT_SANDBOX_APPKEY:} # 虚拟支付沙箱 AppKey offer-id: ${WECHAT_VIRTUAL_PAY_OFFER_ID:1450607643} # 支付应用ID(mp-支付基础配置;productId 才是道具ID) virtual-pay-env: ${WECHAT_VIRTUAL_PAY_ENV:0} # 0=现网 1=沙箱 virtual-pay-token: ${WECHAT_VIRTUAL_PAY_TOKEN:cfcVirtualPay2026} # 消息推送 Token ``` > `config/application*.yml` 模板不在仓库内,服务器部署用环境变量注入。 ## 9. 前端改动 ### 9.1 虚拟支付调用封装(`utils/api.js`) 新增 `requestVirtualPayment(params)` 封装,内部调 `wx.requestVirtualPayment`,与现有 `uni.requestPayment` 分支共存: ```js // 判断虚拟支付可用性(iOS 需 8.0.68+ / iOS 15+,Android 需基础库 2.19.2+) wx.requestVirtualPayment({ orderInfo: params.orderInfo, extInfo: params.extInfo, success(res) { /* 仅提示,最终以服务端订单状态为准 */ }, fail(err) { /* 用户取消/失败,回订单页 */ } }) ``` ### 9.2 页面改造 | 页面 | 改动 | |------|------| | `pages/assessment/*`(测评购买/申请页) | 下单 → 拿 `VirtualPayParams` → `requestVirtualPayment`;支付结果页轮询订单状态 | | `pages/membership/upgrade.vue` | 同样流程替换"直接开通成功"假逻辑 | | `pages/membership/plans.vue`(订阅) | 同样流程 | | `pages/shop/payment/payment.vue` | **不动**(实物商品 v3 路径保留);若未来商城虚拟商品接入,在此加分支 | ## 10. 测试策略 1. **签名单测:** `VirtualPayService` 签名算法对照官方 Python 脚本固定输出断言(文档示例 `c37809f27c...`) 2. **沙箱联调:** env=1 先验证完整链路(iOS 不支持沙箱,iOS 用例走真机现网) 3. **test-mode 兼容:** `wechat.test-mode: true` 下 mock 直接标记已付,跳过虚拟支付(保留现有模拟逻辑) 4. **推送幂等测试:** 重复推送同一 `OutTradeNo` 只处理一次 5. **回归:** 现有实物商品 v3 支付(payment.vue / PaymentService)不受影响 6. **编译验证:** `mvn clean compile`;路由重复检查:`grep -rn '@Mapping' ... | grep -oP '@\w+Mapping\("\K[^"]*' | sort -u` ## 11. 验收标准 - [ ] 测评订单:创建 → 返回虚拟支付参数 → 前端拉起支付 → 发货推送 → 订单 paid → 测评预约确认、报告解锁 - [ ] 会员订单:同样链路 → FamilyMembership 开通、用户升级 FAMILY - [ ] 订阅订单:同样链路 → MemberSubscription 开通、分配专属管家 - [ ] 退款:Android 走 `refund_order`;iOS 问询应答端点可响应;退款推送更新订单状态 - [ ] 道具未配置 → 46001 提示不可购买;停用道具同样拦截 - [ ] 幂等:重复发货推送不重复解锁 - [ ] 实物商品 v3 支付回归通过 - [ ] `virtual_goods_config` 表迁移幂等可重复执行;schema.sql 已同步 - [ ] Web 管理端道具映射 CRUD 可用 - [ ] `mvn clean compile` 通过;无路由重复;无 Bean 命名冲突 ## 12. 风险与依赖 | 项 | 说明 | |----|------| | 微信后台道具配置 | 上线前必须在商户后台建道具并发布至现网,映射表才能生效(开发不阻塞) | | 消息推送 URL 配置 | 需 mp.weixin.qq.com 配置服务器推送 URL;加密模式需联调确认 | | iOS 真机验证 | 沙箱不支持 iOS,需真机现网验证(最低 1 元、iOS 15+、微信 8.0.68+) | | 服务费率 | iOS 端 17%(2026 年腾讯技术服务费限时减免 → 实际 12%),结算周期 Apple 月结 45-60 天 | | 平台处罚 | 不接入虚拟支付的虚拟商品将被平台管控,本改造即合规响应 | ## 13. 未覆盖范围(后续迭代) - 商城虚拟商品(`product_orders` 非实物,如测评额度商品)→ 接入同一 `VirtualPayService` 链路 - 任务模板包(`package_orders`) - 代币充值体系(如需钱包余额功能再评估)