📍 复源AI社区 › 开源解码 › 个人主体微信小程序接入虚拟支付全攻略:开通条件、道具配置、签名发货、会员续期与排错清单

个人主体微信小程序接入虚拟支付全攻略:开通条件、道具配置、签名发货、会员续期与排错清单

摘要:个人开发者的微信小程序也能卖会员了!本文把2026年虚拟支付新规、开通门槛、安卓1%/iOS12%费率、双签名下单、发货推送与查单兜底、退款合规和上线自检清单一次讲透,手把手教你从0跑通支付闭环,少踩坑快上线。
字号 100%
行距 2.05
当前可见 60% 的内容
本文由 莫潇羽@复源AI社区(bbs.fuyuan7.com)撰写,转载请注明出处。

快速摘要

结论先放这里:据官方文档与公开报道,2026年8月底前后,微信开始向个人主体小程序开放虚拟支付能力,个人开发者不注册公司也能在小程序里售卖会员、功能解锁这类虚拟商品。门槛有三条:小程序主体为个人且持有大陆居民身份证、服务类目包含「工具」、已完成认证与备案;个人主体月支付限额为10万元,不支持需要电信业务资质的服务(比如信息发布平台、信息搜索查询)。费率方面,安卓等终端当前按1%收取技术服务费(属于平台限时优惠);iOS端走苹果应用内购买,当前生效费率为12%,这12%全部是苹果佣金,腾讯自身的5%技术服务费在2026年暂免,并非永久固定费率,具体以签约页面为准;安卓资金T+3结算,iOS约45到60天。技术链路的核心只有四件事:拿到 OfferID 和现网 AppKey、在「道具管理」里建好商品并发布、服务端正确生成 paySig 与 signature 两套签名、以「发货推送」为主加 query_order 查单兜底来完成发货。最常见的故障是道具ID或价格与后台不一致、消息推送地址没配或验签失败、以及在开发者工具里测支付。

另外要提醒一句:自2026年4月1日起,小程序里凡是售卖虚拟商品的,平台要求全终端走官方虚拟支付,不能再引导用户去外部网页、公众号或 App 付款。

想看完整拆解,往下翻。


一、先别急着开通:判断你的小程序能不能走这条路

我这两年帮不少朋友看过小程序方案,发现大家在“收钱”这件事上最容易犯的错,不是代码写不对,而是一开始就没搞清楚自己属于哪一类。所以这一章不讲操作,只讲判断。判断错了,后面配置得再漂亮也会卡在审核或者被判违规。

1.1 什么叫“虚拟商品”,什么不算

先给新手一个白话定义:虚拟商品,就是用户付完钱之后,你不需要寄快递、不需要上门,直接在小程序里就能交付的东西。 平台文档里列举得比较清楚,包括会员订阅、充值代币、解锁某项功能、订阅内容、付费功能、打赏、虚拟礼物等等。

反过来,实物商品、线下到店的服务、需要人工上门履约的项目,都不在这个范畴里。实物交易走的是普通微信支付商户号那一套,跟虚拟支付是两条完全不同的通道。

我自己的判断口诀很朴素:付款成功的那一秒,权益能不能在小程序里自动生效? 能,就是虚拟商品;不能,就别往这条路上凑。

下面这张表是我按常见场景整理的,方便你对号入座:

场景

是否属于虚拟商品

备注

工具类小程序的高级会员

是

个人主体最典型、最稳妥的用法

单次解锁某个高级功能(比如批量导出)

是

适合做成一次性道具

次数包、点数包

是

注意与“代币”概念区分,个人主体官方建议只做道具直购

录播课程、音视频内容

是

但要看类目是否匹配,个人主体只有「工具」类目可用

实物周边、纸质书

否

走普通支付,个人主体本身也开不了

线下课、上门服务

否

同上

1.2 个人主体的三道硬门槛

根据微信开放文档“虚拟支付:个人”一页的说明,个人主体想开通,需要同时满足三个条件:

条件

具体要求

主体类型

小程序主体为个人,开发者持有中国大陆居民身份证

服务类目

小程序的服务类目里包含「工具」

认证与备案

小程序已完成微信认证,并完成备案

这三条里,最容易被忽略的是类目。很多人注册个人小程序的时候随手选了个类目,等到想开虚拟支付才发现不符合。我的建议是,开通之前先去小程序管理后台看一眼“服务类目”,没有「工具」就先补上,补类目本身也要审核,别等到要上线了才去折腾。

认证这个词给新手解释一下:它指的是微信对小程序主体身份的核验,个人主体一般通过扫码、填写信息、支付认证费用来完成,认证费用以微信后台的实时标价为准。备案则是国内互联网信息服务的常规要求,小程序上线前需要通过平台提交备案信息,等待管局审核。这两步没走完,虚拟支付的入口你大概率是看不到的。

1.3 限额与“不支持的服务”

个人主体有一个明确的上限:月支付限额10万元。这个数字是全终端合计的口径,官方在接入文档的检查清单里特意强调了这一点。

我对这个额度的理解是:它的定位更接近“让个人开发者给自己的小工具配上合规付费能力”,而不是鼓励个人以此开展规模化的经营活动。对于做学习项目、验证产品想法的个人开发者来说,这个空间是够用的。如果你的业务规模确实在持续扩大,或者本身就打算长期、正式地对外经营,那更稳妥的做法是依法办理个体工商户或企业主体,按经营主体的规则开通相应能力,同时把财务、税务等事项交给专业人士把关,而不是一直停留在个人主体上。

另一条限制更需要当心:不支持需办理电信业务相关资质的服务,文档举的例子有电子邮件、语音信箱、储存转发、信息发布平台、信息搜索查询等。这条看着抽象,我换成大白话说:

  • 如果你的小程序本质上是一个“让用户发帖、让别人来看”的平台,那就属于信息发布类,个人主体做不了;
  • 如果你的小程序是“帮用户检索某类信息”的查询工具,也要格外谨慎;
  • 如果你打算卖的“付费内容”其实是一堆第三方资料的下载链接,这里面还牵扯到版权问题,风险更大。

我踩过的一个坑就在这儿:早期我想做一个“付费解锁资料合集”的个人小程序,后来仔细一琢磨,资料本身的授权说不清楚,形态上又接近信息分发,干脆放弃了,改成了纯工具功能的会员制。个人主体最稳的路线,是把付费点放在“工具能力”本身,而不是“内容搬运”上。

1.4 2026年规则的一个大变化

这里必须单独说一下时间线,因为它直接决定你“要不要做”,而不只是“能不能做”。

2026年2月27日,微信团队发布了《小程序虚拟支付业务管理规范更新公告》,核心意思有两层:一是小程序全面支持iOS端虚拟支付;二是涉及虚拟支付业务的小程序,需要在4月1日前完成全终端(iOS、安卓、Windows、鸿蒙)接入,到期未接入会被判定违规,平台会视情况采取风险提醒、限制功能乃至暂停服务等措施。

公告还再次重申了一个老规矩:不得引导用户到 App、公众号、H5、个人号或外部网站完成支付。 所以以前那种“iOS端显示去公众号购买”的做法,现在已经彻底走不通了。

3月中旬,平台又对iOS端费率做了调整:据微信开放社区的公告,3月15日之后,iOS端2026年实际收取的费率由15%下调到12%,全部为苹果佣金,腾讯5%的技术服务费在2026年暂不收取。

到了8月底前后,微信开放文档上线了“虚拟支付:个人”页面,平台在符合条件的类目范围内向个人主体开放了这项能力,同期也有不少开发者在公开渠道分享了这一变化。时间线大致如下:

timeline
    title 小程序虚拟支付规则演变(示意)
    早期 : 主要面向小游戏与部分类目
         : iOS端不允许展示购买入口
    2026年2月底 : 发布规范更新公告
                : iOS端虚拟支付全面开放
    2026年3月15日 : iOS端实际费率调整为12%
    2026年4月1日 : 全终端强制接入官方虚拟支付
    2026年8月底 : 个人主体在「工具」类目下可开通
平台规则调整比较频繁,上面的时间线是我写稿时根据官方文档与公开报道整理的,具体生效口径请以微信公众平台与开放文档的最新公告为准。

1.5 我的判断流程图

把上面几条串起来,我自己用的是下面这个判断流程。建议你照着走一遍,任何一个节点卡住,就先把那个节点解决掉再往后看:

flowchart TD
    A[我想在小程序里收费] --> B{卖的是虚拟商品吗}
    B -- 否 --> X1[走普通微信支付,个人主体不适用]
    B -- 是 --> C{主体是个人吗}
    C -- 否 --> X2[参考企业与个体户的开通文档]
    C -- 是 --> D{类目含「工具」吗}
    D -- 否 --> E[先补充工具类目并等审核]
    D -- 是 --> F{已认证且已备案吗}
    F -- 否 --> G[先完成认证与备案]
    F -- 是 --> H{业务涉及信息发布或检索平台吗}
    H -- 是 --> X3[个人主体不支持,调整产品形态]
    H -- 否 --> I[可以申请开通虚拟支付]

这一章的篇幅我刻意写得比较长,因为我见过太多人在代码层面死磕了一周,最后发现是类目不对。先把“能不能”想清楚,是最省时间的一步。


二、把整条支付链路看明白:从点“购买”到权益生效

判断完能做,接下来别急着去后台点按钮。我的习惯是先在纸上把整条链路画出来,搞清楚每一步是谁在做、数据从哪来到哪去。链路清楚了,后面每个配置项为什么要填、填错了会在哪一步报错,心里就都有数了。

2.1 参与这件事的几个角色

一笔虚拟支付,至少有四个角色参与:

角色

它负责什么

新手理解

小程序前端

展示商品、发起购买、调起支付面板

用户手机上看到的那一层

你的服务器

生成订单号、计算签名、接收通知、发放权益

你自己的后台程序

微信平台

展示支付界面、扣款、通知你的服务器

中间的“收银台”

MP 管理后台

配置商户信息、道具、推送地址、查看账单

你登录网页操作的地方

这里“MP后台”指的是微信公众平台里的小程序管理后台,平时说的“小程序后台”就是它。

2.2 一笔订单的完整生命周期

下面这张时序图是我对照官方接入文档,按自己的理解重新画的。建议多看两遍,后面第四章写服务端代码,基本就是在实现这张图:

sequenceDiagram
    participant U as 用户
    participant FE as 小程序前端
    participant SV as 你的服务器
    participant WX as 微信平台
    U->>FE: 点击「购买」
    FE->>SV: 请求下单(带登录态和商品ID)
    SV->>SV: 生成唯一业务单号,订单记为待支付
    SV->>SV: 计算 paySig 与 signature
    SV-->>FE: 返回签名后的支付参数
    FE->>WX: 调用 wx.requestVirtualPayment
    WX-->>U: 弹出支付面板,用户确认付款
    WX->>SV: 推送发货通知 xpay_goods_deliver_notify
    SV->>SV: 验签、幂等检查、发放权益
    SV-->>WX: 返回 ErrCode 0 表示已处理
    FE->>SV: 查询订单状态
    SV-->>FE: 已支付,权益已生效
    Note over SV,WX: 若推送丢失,服务器定时调用 query_order 查单补发

这里有两个关键概念,新手一定要分清。

发货推送,指的是用户付款成功以后,微信平台主动向你服务器发一条消息,告诉你“这笔钱到了,可以给用户发货了”。虚拟商品的“发货”,其实就是在你的数据库里把会员到期时间延长、或者把某个功能标记为已解锁。

幂等,这个词听着玄乎,意思其实很简单:同一笔订单,不管通知来了一次还是十次,你都只发一次货。平台在你没有正确回复的情况下会重试推送,官方文档写的是最多重试15次,所以幂等处理不是可选项,是必选项。

2.3 为什么前端的“支付成功”不能当发货依据

这是我觉得最值得单独拎出来讲的一点。

很多第一次做支付的朋友,会很自然地在前端 success 回调里直接调一个接口,让服务器给用户开通会员。看起来逻辑很顺,实际上漏洞很大:

第一,回调可能丢。用户付完款立刻把小程序划掉、网络抖一下、手机没电了,success 回调都可能根本执行不到。结果就是钱扣了,权益没给,用户来投诉。

第二,前端请求可以被伪造。任何懂一点抓包的人,都可以绕过支付直接调用你那个“开通会员”的接口。

所以官方文档的建议写得很明确:以平台的发货推送为主,推送丢失时用 query_order 主动查单兜底,前端回调只用来刷新界面、给用户一个提示。我的做法是,前端收到成功回调以后只做一件事——轮询自己服务器的订单状态,查到“已支付”再把页面切换成已解锁的样子。

2.4 两个单号,别搞混

链路里会同时出现两个订单号,这也是新手常晕的地方:

单号

谁生成

用途

outTradeNo

你的服务器

你自己的业务单号,下单时必填,必须唯一

wx_order_id

微信平台

平台单号,跟踪订单、对账、幂等去重都以它为准

在发货推送的XML里,平台单号放在 WeChatPayInfo.MchOrderNo 这个字段下面,业务单号放在 OutTradeNo 字段。我的数据库里两个都存,并且给 wx_order_id 加了唯一索引,这样即便代码有疏漏,数据库层面也能拦住重复发货。

官方对 outTradeNo 的格式也有要求:每次下单都要重新生成,长度8到32位,不能以下划线开头,不能复用。我一般用“字母前缀 + 时间戳 + 随机数”的组合,简单够用。

2.5 我把链路拆成的四个“模块”

为了后面写代码和排错方便,我把整条链路拆成了四个相对独立的模块,这也是本文后半部分的组织方式:

flowchart LR
    M1[模块一<br/>后台开通与凭证] --> M2[模块二<br/>服务端下单与签名]
    M2 --> M3[模块三<br/>前端拉起支付]
    M3 --> M4[模块四<br/>发货推送与查单兜底]
    M4 --> M5[权益生效<br/>会员期计算]

模块一只需要在网页后台操作一次;模块二和模块四是服务端的核心工作量;模块三反而是最简单的,前端几十行代码就能搞定。莫潇羽在这里多说一句:很多人觉得支付难,其实难的从来不是“调起支付面板”,而是“付完钱之后,怎么确保权益一定发到、而且只发一次”。把这件事想透,整个接入就成功了一大半。


三、后台开通:拿到三项关键凭证并建好商品

链路想清楚了,这一章回到网页后台,把“模块一”走完。整个过程并不复杂,但有几个地方如果不提前知道,很容易来回折腾。

3.1 开通入口与步骤

按照个人主体文档的描述,开通步骤大致是这样的:登录小程序管理后台,在左侧栏找到【支付与交易】下面的【虚拟支付】,点击开通,勾选同意协议,然后填写个人资料,包括个人身份信息、提现账户、支付管理员。提交后等待审核,审核通过后刷新页面、扫码完成签约。

我把它画成流程,方便对照:

flowchart TD
    S1[登录小程序管理后台] --> S2[左侧栏:支付与交易 → 虚拟支付]
    S2 --> S3[点击开通并勾选协议]
    S3 --> S4[填写身份信息、提现账户、支付管理员]
    S4 --> S5[提交审核]
    S5 --> S6[刷新页面确认审核通过]
    S6 --> S7[扫码签约]
    S7 --> S8[进入虚拟支付商户管理模块]

关于审核时长,个人主体文档写的是一般在5分钟以内,企业与个体户的文档写的是一般1到7个工作日。两者差别挺大,我理解是个人主体的资料更简单、核验路径更短。实际时长还会受平台排队情况影响,以后台显示为准。

几个我建议提前准备的东西:

  • 提现账户:一般是本人名下的银行卡,姓名要和身份证一致,否则审核或者后续提现会出问题;
  • 支付管理员:负责后续扫码确认一些敏感操作的人,个人主体通常就是你自己;
  • 手机上的微信:签约、账户验证都需要扫码,别在只有电脑的环境里操作。

还有一点,企业文档里提到“账户验证”和“签约”不一定即时完成,可以先退出页面,后续再进入会接着之前的步骤继续。这一点个人主体在实际操作中也可能遇到,别以为卡住了就反复重新提交。

3.2 开通后要记下的三项凭证

开通完成后,虚拟支付栏目会变成一个商户管理模块。这时候有三项信息,是后面写代码必须用到的:

凭证

它是什么

在哪里找

泄露风险

AppID

小程序的唯一身份标识

小程序后台 → 设置

低,属于公开信息

OfferID

你在虚拟支付体系里的商户号

虚拟支付 → 基本配置

中,别随意公开

现网 AppKey

计算支付签名用的密钥

虚拟支付 → 基本配置

高,只能放在服务器

AppKey 一定只能放在服务器端。我见过有人为了图省事,把 AppKey 写进小程序前端代码里直接算签名,这等于把保险柜钥匙挂在门上。小程序的代码包是可以被解包查看的,一旦泄露,别人就能伪造支付请求。

我的做法是把这些值放进服务器的环境变量,或者云开发的环境配置里,代码里只读变量名:

# 服务器环境变量示例(值均为示意,请替换为你自己后台的真实值)
export WX_APPID="wx0000000000000000"
export WX_APPSECRET="你的小程序AppSecret"
export XPAY_OFFER_ID="你的OfferID"
export XPAY_APP_KEY="你的现网AppKey"
export WX_MSG_TOKEN="你在消息推送里自定义的Token"

顺便解释一下 AppSecret:它是小程序本身的密钥,用来调用 code2Session 换取用户的 session_key,后面算用户态签名时要用到。它跟 AppKey 不是一个东西,但同样只能放在服务器上。

3.3 在「道具管理」里创建商品

虚拟支付里有两个概念:代币和道具。代币类似游戏币,用户先充值、再用币去买东西;道具就是一个具体的商品,用户直接花钱买。个人主体文档在注意事项里写得很清楚,接入范围只做道具直购,不引入代币等额外功能。所以个人开发者只需要关心道具。

在【道具管理】里,每创建一个道具,平台会分配一个道具ID,同时你要设置这个道具的价格。创建完成后记得发布,没发布的道具在现网是用不了的。

我自己的习惯,是先在纸上把商品体系设计好,再去后台逐个创建,避免建了又删。以我虚构的一个“图片批量处理小工具”为例,商品体系可以这样规划:

商品

类型

后台道具价格

发放逻辑

月度会员

时长型

按你的定价填写

会员期延长30天

季度会员

时长型

按你的定价填写

会员期延长90天

批量导出次数包

次数型

按你的定价填写

可用次数增加50次

高清导出永久解锁

买断型

按你的定价填写

永久开启高清导出

这里我故意没写具体价格,定价跟你的产品价值和用户群体有关,别人的数字参考意义不大。

价格单位要特别注意:签名参数里的道具单价是以“分”为单位的,官方明确要求全程不要换算,并且必须与后台道具价格一致。比如后台价格是9.9元,参数里就应该是990。我建议在自己的数据库里也统一用“分”存储价格,从根上杜绝换算错误。

3.4 配置消息推送地址

这一步是很多人漏掉的,也是后面“钱扣了但权益没到”这种诡异问题的最大元凶,所以我单独列一节。

发货推送不是虚拟支付模块自己单独推的,而是走小程序的消息推送能力。配置入口在小程序后台侧边栏的【开发与服务】→【开发管理】→【消息推送】。这里需要填几个东西:

配置项

含义

URL

你服务器上接收推送的接口地址,必须公网可访问,一般要求 HTTPS

Token

你自己设定的一串字符,用于验证消息确实来自微信

EncodingAESKey

消息加解密用的密钥,可以随机生成

消息加密方式

明文、兼容或安全模式

数据格式

XML 或 JSON

Token 是你自己定的,不是平台自动发给你的。 这一点跟 AppKey 不一样,很多人以为开通后在哪个页面能“找到” Token,找半天找不到。实际上是你在这个页面自己填一个,然后在服务器代码里填同一个值。

点击提交的时候,微信会立刻向你填的 URL 发一个验证请求,你的服务器必须按规则正确响应,配置才能保存成功。所以正确的顺序是:先把服务器的接收接口部署上线,再回后台填配置。 顺序反了,就会一直提示验证失败。

如果你使用微信云开发,官方文档提到可以借助开发者工具里的 Skills 能力自动完成消息推送接收的部署与配置,能省掉不少手工步骤。

3.5 iOS 端的额外一步

如果你希望 iOS 用户也能付款,还需要配置小程序简称,因为苹果的支付界面需要展示一个显示名称。配置入口在小程序后台左下角头像图标 → 账号设置 → 基本信息。配置好之后,才能在虚拟支付的基本配置里开通苹果的应用内支付。

另外还有两个版本门槛:iOS 端需要微信客户端8.0.68及以上版本;安卓、鸿蒙、Windows 端需要基础库2.19.2及以上版本。第四章写前端代码时,我会给出版本校验的写法。

到这里,模块一就全部完成了。建议你拿个小本子,把 AppID、OfferID、AppKey、Token、道具ID和价格逐项记下来,后面对照排错会用到。莫潇羽@复源AI社区 的经验是,这张“配置清单”能帮你省下至少一半的排错时间。


四、服务端实现:下单、双签名、发货推送与查单兜底

这一章是全文代码量最大的部分,对应第二章里的“模块二”和“模块四”。官方文档给的是 Python 示例,我这里换成 Node.js 的写法,思路是一样的,你用 PHP、Java、Go 都可以对照改写。

先说明一下,下面的代码是我为了讲清原理整理的教学示意,请勿直接用于生产环境。为了突出主线,代码里省略了不少工程上必须要有的东西,包括但不限于:

省略的环节

生产环境应当补上的做法

参数校验

对前端传入的每个字段做类型、长度、白名单校验

鉴权

下单、查询接口必须校验登录态,不能只信任前端传来的 openid

重放攻击防护

校验推送的 timestamp 是否在合理时间窗口内,并对 nonce 做短期去重

日志审计

下单、推送、查单、发货、退款全链路留痕,敏感字段脱敏

密钥管理

AppKey、AppSecret、Token 放在环境变量或密钥管理服务,定期轮换

限流与告警

对下单接口限流,对异常订单设置监控告警

上线前请结合官方文档与自身的安全规范逐项补齐、逐项核对。

4.1 两套签名,到底在签什么

虚拟支付的下单参数里有两个签名字段,新手第一次看很容易混:

签名

密钥

签名内容

作用

paySig

现网 AppKey

字符串“requestVirtualPayment&”拼上 signData

证明这笔订单是商户服务器发起的

signature

用户的 session_key

signData 本身

证明这笔订单属于当前登录用户

两个签名用的算法都是 HMAC-SHA256。给新手解释一下:HMAC 是一种“带密钥的摘要算法”,同样的内容配上不同的密钥,算出来的结果完全不同;只有知道密钥的人才能算出正确结果,所以可以用来防伪。

用纯文本把公式写出来是这样的:

paySig    = HMAC_SHA256( key = AppKey,     message = "requestVirtualPayment" + "&" + signData )
signature = HMAC_SHA256( key = session_key, message = signData )

其中 signData 是一个 JSON 字符串。最关键的一点:参与签名的 signData,必须和最终传给前端的 signData 是同一个字符串,一个字节都不能差。 不能先签名、再重新序列化一次对象传出去,因为重新序列化可能改变键的顺序或空格,导致签名对不上。

这里还有一个细节容易被忽视:服务端调用 /xpay/* 这类接口时,同样要算 pay_sig,但签名里的 uri 就不是固定的“requestVirtualPayment”了,而是实际的接口路径,比如 /xpay/query_order。

4.2 signData 里有哪些字段

对照官方文档,signData 的字段如下:

字段

含义

取值说明

offerId

虚拟支付商户号

即 OfferID

buyQuantity

购买数量

一般为1

env

环境标识

个人主体文档要求固定填0,即现网

currencyType

币种

固定 CNY

productId

道具ID

与后台道具ID完全一致

goodsPrice

道具单价

单位为分,与后台价格一致

outTradeNo

业务单号

唯一,8到32位

attach

透传数据

必填,发货时原样带回

外层还有一个 mode 字段,道具直购固定填 short_series_goods。

关于 env 多说一句:企业与个体户的文档里提到可以先用沙箱环境测试,并提醒现网环境会收取技术服务费;而个人主体文档的注意事项里直接写了 env 固定填0。所以个人开发者如果想低成本验证,我的建议是把一个测试道具的价格设得很低,用真实小额订单跑通全链路,这也正是官方检查清单里“上线后用小额真单验证”的思路。

4.3 下单接口:生成参数与签名

下面是下单接口的示意代码。前端调用这个接口时,需要带上登录凭证,服务端据此找到用户的 openid 和 session_key:

// server/pay.js —— 教学示意,请勿直接用于生产环境(已省略鉴权、参数校验、日志审计与异常处理)
const crypto = require('crypto');

const OFFER_ID = process.env.XPAY_OFFER_ID;
const APP_KEY  = process.env.XPAY_APP_KEY;

// HMAC-SHA256 工具函数
function hmacSha256(key, message) {
  return crypto.createHmac('sha256', key).update(message, 'utf8').digest('hex');
}

// 生成业务单号:字母前缀 + 时间戳 + 随机数,长度控制在 8 到 32 位
function genOutTradeNo() {
  const rand = Math.floor(Math.random() * 1e6).toString().padStart(6, '0');
  return 'T' + Date.now() + rand;   // 例如 T1757990000000123456,共 20 位
}

// POST /pay/order
async function createOrder(req, res) {
  const { productKey } = req.body;              // 前端传来的是你自己的商品标识
  const user = req.user;                        // 由登录中间件注入,含 openid 与 sessionKey
  const product = await db.products.findByKey(productKey);
  if (!product || !product.onSale) return res.status(400).json({ msg: '商品不可购买' });

  const outTradeNo = genOutTradeNo();

  // 先落库,状态为待支付
  await db.orders.insert({
    outTradeNo,
    openid: user.openid,
    productId: product.wxProductId,
    priceFen: product.priceFen,
    status: 'PENDING',
    createdAt: new Date()
  });

  const signDataObj = {
    offerId: OFFER_ID,
    buyQuantity: 1,
    env: 0,
    currencyType: 'CNY',
    productId: product.wxProductId,   // 必须与后台道具ID完全一致
    goodsPrice: product.priceFen,     // 单位:分
    outTradeNo,
    attach: JSON.stringify({ k: productKey })
  };

  // 只序列化一次,签名和返回都用这同一个字符串
  const signData = JSON.stringify(signDataObj);
  const paySig = hmacSha256(APP_KEY, 'requestVirtualPayment&' + signData);
  const signature = hmacSha256(user.sessionKey, signData);

  res.json({
    outTradeNo,
    payData: { signData, mode: 'short_series_goods', paySig, signature }
  });
}

这段代码里我想强调两个设计点。

第一,前端只传“你自己的商品标识”,价格和道具ID都由服务器查库决定。千万别让前端把价格传过来,否则就给了别人改价的机会。

第二,session_key 要保证是最新的。用户的 session_key 会在重新登录时变化,如果你的服务器存的是旧值,signature 就会算错。我的做法是,每次进入购买流程前,前端先调一次 wx.login 拿到新的 code,服务端用 code2Session 刷新一下 session_key 再下单。这一步多花几十毫秒,但能避免一大类签名失败的问题。

4.4 接收消息推送:先过验证,再处理发货

接收推送的接口要处理两类请求:一类是你在后台保存配置时,微信发来的地址验证请求(GET);另一类是真正的事件推送(POST)。

地址验证的规则是:把 Token、timestamp、nonce 三个字符串按字典序排序后拼接,做 SHA1 摘要,与请求里的 signature 对比,一致就原样返回 echostr。用纯文本写出来:

待签名串 = sort([Token, timestamp, nonce]).join("")
校验通过条件:SHA1(待签名串) == 请求参数 signature

这里要把请求方式分清楚:保存配置时的地址验证是 GET 请求,验签通过后原样返回 echostr 参数即可,不涉及业务逻辑;真正的事件推送是 POST 请求,验签通过后才解析消息体、处理发货。

特别提醒:消息加密方式的选择会直接影响代码写法。 如果你在后台选的是“安全模式”,POST 过来的消息体是加密的密文,XML 里只有一个 Encrypt 字段,必须先用 EncodingAESKey 按官方的消息加解密规则解密,才能拿到 Event、OutTradeNo 这些字段;安全模式下还需要按官方规则校验消息签名 msg_signature。如果直接拿密文去解析,你会发现字段全是空的,发货逻辑根本走不进去。我的建议是:开发调试阶段可以先用明文模式跑通,上线前再切换到安全模式并补上解密逻辑,切换后务必重新做一笔真实支付验证。

下面的示意代码以明文模式、XML 格式为例:

// server/notify.js —— 教学示意,请勿直接用于生产环境(未包含安全模式解密与重放防护)
const TOKEN = process.env.WX_MSG_TOKEN;

function checkWxSignature({ signature, timestamp, nonce }) {
  const str = [TOKEN, timestamp, nonce].sort().join('');
  const sha1 = crypto.createHash('sha1').update(str).digest('hex');
  return sha1 === signature;
}

// GET /pay/notify —— 保存消息推送配置时的地址验证
function verifyUrl(req, res) {
  if (checkWxSignature(req.query)) return res.send(req.query.echostr);
  res.status(403).send('invalid');
}

// POST /pay/notify —— 事件推送
async function handleNotify(req, res) {
  if (!checkWxSignature(req.query)) return res.status(403).send('invalid');

  const msg = await parseXml(req.rawBody);        // 解析 XML,得到对象
  if (msg.Event !== 'xpay_goods_deliver_notify') {
    return res.send('success');                   // 其他事件按需处理
  }

  const wxOrderId  = msg.WeChatPayInfo.MchOrderNo; // 平台单号,幂等依据
  const outTradeNo = msg.OutTradeNo;
  const openid     = msg.OpenId;
  const productId  = msg.GoodsInfo.ProductId;
  const quantity   = Number(msg.GoodsInfo.Quantity || 1);

  try {
    await deliverOnce({ wxOrderId, outTradeNo, openid, productId, quantity });
    res.type('xml').send('<xml><ErrCode>0</ErrCode><ErrMsg><![CDATA[success]]></ErrMsg></xml>');
  } catch (e) {
    // 返回非 0,平台会重试推送
    res.type('xml').send('<xml><ErrCode>1</ErrCode><ErrMsg><![CDATA[retry]]></ErrMsg></xml>');
  }
}

4.5 发货函数:幂等是生命线

发货函数是整个系统里最需要严谨的地方。我的实现思路是“先抢占、再发货”,用数据库事务保证原子性:

// 幂等发货:同一 wxOrderId 只会成功处理一次
async function deliverOnce({ wxOrderId, outTradeNo, openid, productId, quantity }) {
  await db.transaction(async (tx) => {
    const order = await tx.orders.findByOutTradeNo(outTradeNo, { forUpdate: true });
    if (!order) throw new Error('订单不存在');
    if (order.status === 'PAID') return;                 // 已处理过,直接视为成功

    // 防御性校验:推送里的用户与道具要和下单时一致
    if (order.openid !== openid || order.productId !== productId) {
      throw new Error('订单信息不一致');
    }

    await tx.orders.update(order.id, {
      status: 'PAID',
      wxOrderId,                                         // 数据库对 wxOrderId 建唯一索引
      paidAt: new Date()
    });

    await grantBenefit(tx, openid, productId, quantity); // 按商品类型发放权益,见第五章
  });
}

这段代码里的 forUpdate 是行锁,意思是在事务结束前,别的请求不能同时修改这条订单。平台重试推送、兜底查单同时触发的时候,行锁加上状态判断,就能确保只发一次货。

4.6 查单兜底:别把命运全交给推送

推送不是百分之百可靠的,服务器重启、网络抖动、证书过期,都可能让某一次推送没接住。官方给的兜底方案是调用 query_order 接口主动查单,建议每5分钟左右查一次。

我的做法是写一个定时任务,专门扫描“创建超过2分钟、但仍是待支付”的订单,逐个去平台查询:

// 定时任务:兜底查单(教学示意,请勿直接用于生产环境)
async function reconcilePendingOrders() {
  const orders = await db.orders.findPending({ olderThanMinutes: 2, limit: 100 });
  for (const o of orders) {
    const body = JSON.stringify({ openid: o.openid, env: 0, order_id: o.outTradeNo });
    const uri = '/xpay/query_order';
    const paySig = hmacSha256(APP_KEY, uri + '&' + body);   // 服务端接口的 uri 是实际路径
    const token = await getAccessToken();                   // 小程序接口调用凭证
    const resp = await httpPost(
      `https://api.weixin.qq.com${uri}?access_token=${token}&pay_sig=${paySig}`,
      body                                                  // 发出的必须是参与签名的同一串
    );
    // 根据返回结果判断订单是否已支付,已支付则调用 deliverOnce 补发
    // 具体返回字段与状态码请以官方接口文档为准
  }
}

注意这里又出现了“签名串必须和实际请求体完全一致”的要求,不要先签名再用 HTTP 库自动序列化对象。另外,超过一定时间仍未支付的订单,建议标记为“已关闭”,避免定时任务无限扫描。

4.7 服务端接口清单

最后把服务端需要实现的接口归拢一下:

接口

方法

作用

/pay/order

POST

生成业务单号、signData 与两套签名,返回给前端

/pay/notify

GET

消息推送的地址验证

/pay/notify

POST

接收发货推送,验签后幂等发货

/pay/status

GET

供前端轮询订单状态

定时任务

—

调用 query_order 兜底补发

我个人的体会是,服务端这一套写完、跑通一次以后,后面新增商品几乎不用改代码,只需要在后台建道具、在自己的商品表里加一行。这也是虚拟支付对个人开发者友好的地方:复杂度一次性投入,后续维护成本很低。


五、前端与商品模型:拉起支付、权益展示与会员时长计算

服务端搭好以后,前端的工作就轻松多了。这一章除了讲前端怎么调起支付,我还想花点篇幅聊聊“商品数据怎么设计”,因为这决定了你以后扩展商品时顺不顺手。

5.1 购买前的版本校验

前面提到,iOS 端要求微信客户端8.0.68及以上版本。如果用户版本太旧,直接调支付会失败,体验很差。所以购买按钮点下去的第一件事,是做版本判断:

// utils/version.js —— 比较版本号,返回 true 表示当前版本不低于要求
function versionGte(current, required) {
  const a = String(current).split('.').map(Number);
  const b = String(required).split('.').map(Number);
  for (let i = 0; i < Math.max(a.length, b.length); i++) {
    const x = a[i] || 0, y = b[i] || 0;
    if (x !== y) return x > y;
  }
  return true;
}

function canPayOnThisDevice() {
  const device = wx.getDeviceInfo ? wx.getDeviceInfo() : wx.getSystemInfoSync();
  const app = wx.getAppBaseInfo ? wx.getAppBaseInfo() : wx.getSystemInfoSync();
  if (device.platform === 'ios' && !versionGte(app.version, '8.0.68')) {
    wx.showModal({ title: '提示', content: '请先将微信更新到最新版本再购买', showCancel: false });
    return false;
  }
  if (!wx.canIUse('requestVirtualPayment')) {
    wx.showModal({ title: '提示', content: '当前微信版本暂不支持该功能,请升级后重
🔒
该内容仅对更高等级社区用户开放
请谨慎解锁时效性强且发布日期较早的文章
单篇解锁后若未显示全文请刷新页面
您当前:游客 · 可见 60% 内容 · 升级至 注册用户 可见 70%
👀
游客
可见 60%
✓ 当前
注册用户
注册用户
可见 70%
社区精英
社区精英
可见 100%
社区守护
社区守护
可见 100%
仅解锁本文,永久有效。如需PDF珍藏版,请联系站长获取。 当前单篇价格 ¥9.9
✏️ 发表评论

请先登录后发表评论

前往登录
📊 站点统计
今日发布0 篇
文章总数1107 篇
昨日发布0 篇
本月发布31 篇
建站时间457 天
🔍 搜索
站长微语

联系站长

QQ:2805463528
AIGC 技术社区
致力于解码 AI前沿技术 与经验分享
纯粹的技术应用社区

💡 欢迎您的建议与反馈,让社区变得更好

快速通道
联系站长
站长QQ二维码
AI交流群
AI交流群
仍在路上

那些寒夜里追赶过的方向

那些冷眼下没放弃的理想

一篇一篇写到现在

仍在路上

"不羁放纵爱自由"

—— 致敬 Beyond
持续创作中 莫潇羽 · 复源AI社区
▲