Android 微信支付 App 接入:统一下单、签名、回调与异步通知
2026/9/18 14:42:59 网站建设 项目流程

1. 动手之前的整体设计:微信支付不能照着文档抄一遍

1.1 支付链路里到底有几个角色在说话

很多人第一次接微信支付,脑子里只有一句话:调个接口把钱收了。真上手才发现,这条链路上至少有四个角色在相互说话——你的 Android 客户端、你自己的服务端、微信的支付网关、以及用户手机里的微信 App。四者之间的信任关系是不对等的,这一点想不清楚,后面写多少代码都会翻车。

先把一次成功支付的完整时序捋一遍。用户在 App 里点了“确认支付”,客户端把订单信息(商品 ID、数量等)发给自己的服务端;服务端拿着商户号、密钥,向微信网关请求下单,拿回一个 prepay_id;服务端把这个 prepay_id 加上时间戳、随机串、签名,拼成一包参数回给客户端;客户端调用微信 SDK 的 sendReq 把这包参数丢给微信 App;微信 App 弹出收银台,用户输密码或指纹;支付完成后微信先同步回调你的客户端(WXPayEntryActivity),再异步通知你的服务端(notify_url);你的服务端收到异步通知、验签、改订单状态,然后回一个 success 的 XML/JSON 给微信,整个流程才算闭环。

这里面最关键的一条认知是:客户端拿到的支付结果只能用来做 UI 展示,不能用来发货。客户端可以被反编译、可以被 Hook、可以伪造回调,唯一可信的凭据是微信服务器发给你服务器的异步通知。我见过不止一个项目在 onResp 里直接把订单标成已支付,结果被人用一个改包工具就薅走了商品,这种教训不值得重复。

还有一个容易被忽略的角色分工问题:签名动作永远发生在服务端。API 密钥、商户私钥这些东西一旦出现在 APK 里,等于把保险柜钥匙贴在门上。哪怕你觉得自己做了混淆、做了加固,反编译一个字符串常量也就是几分钟的事。所以客户端这一侧的任务非常单纯——组装、调起、收结果、通知服务端,仅此而已。

1.2 三种接入方式的取舍:App支付、JSAPI、Native扫码

微信支付不是一个单一接口,而是一族产品。选错类型是新手最常见的返工原因。下面这张表是我自己整理过的对照,基本覆盖了日常会碰到的场景:

支付类型trade_type适用场景调起方式是否需要额外资质
App 支付APP原生 Android/iOS 应用内收款客户端 SDK sendReq需开放平台移动应用
JSAPI 支付JSAPI公众号网页、微信内 H5WeixinJSBridge需公众号 + 授权域名
Native 扫码NATIVEPC 网站、收银台大屏返回二维码链接需 PC 网站备案
小程序支付JSAPI微信小程序内wx.requestPayment需小程序主体
H5 支付MWEB微信外浏览器跳转 URL需额外申请

Android 原生 App 走的就是第一行。这里有个特别典型的坑:有些人为了省事,在 App 内嵌 WebView 里加载网页下单,然后走 JSAPI。这条路的用户体验很差——WebView 里没法直接唤起微信,需要各种跳转和回跳,而且微信对 JSAPI 的授权域名校验很严格,稍有不符就报“当前页面的 URL 未注册”。原生 App 就用原生 App 支付,别绕。

至于资质这块,App 支付需要在微信开放平台注册移动应用,拿到 AppID,并且把你的应用签名(MD5 值)填进去。注意是应用签名,不是签名文件的 SHA1 或 SHA256,是那个去掉冒号、转成小写的 MD5。这个值填错了,表现就是 SDK 能初始化、能调起微信,但微信那边直接拒绝,回调 -1,日志里什么有用信息都没有。我后面会专门讲怎么用工具把这个值取出来。

1.3 一个被反复踩的坑:客户端不能碰金额和签名

这条单独拎出来说,因为它是我见过造成线上事故最多的一条。

正确的做法是:客户端提交业务标识,比如商品 ID、套餐编号、订单号,服务端根据这些标识去数据库里查出真实金额,然后下单。客户端绝对不允许把 total_fee 传上来,更不允许服务端信任客户端传来的金额。原因很简单,抓个包改个数字,一分钱买年费会员这种事就发生了。

签名同理。有些教程为了演示方便,把 API 密钥写在 Android 代码里,让客户端本地算签名,然后直接调微信的下单接口。这种写法只能存在于 Demo 里,一旦上线就是灾难。密钥泄露之后,别人可以用你的商户号随意发起下单、发起退款,损失是实打实的。

我现在做这类项目的固定做法是:服务端提供一个“创建订单”接口,客户端传商品 SKU 和数量,服务端落库拿到 out_trade_no,然后内部再调微信下单,最后把调起参数回给客户端。客户端全程不知道密钥长什么样,也不知道 total_fee 是多少。这样即使 APK 被反编译,能拿到的也只是几个无关痛痒的接口地址。

2. 开工前的账号与工程准备

2.1 商户平台侧需要拿到的五样东西

在动代码之前,先把账号侧的东西凑齐,否则写到一半卡住会很痛苦。需要准备的东西我列个清单:

  • AppID:微信开放平台移动应用的 AppID,形如 wx 开头的一串字符。注意它和公众号的 AppID、小程序的 AppID 是三个不同的东西,不能混用。
  • 商户号(mch_id):商户平台里的商户号,纯数字,一般是 8 到 10 位。
  • API 密钥(APIv2 key):32 位字符串,在商户平台“账户中心 - API 安全”里设置。这个密钥只显示一次,设置完自己找地方存好。
  • APIv3 密钥:如果打算走 v3 接口,还需要单独设置一个 32 位 APIv3 密钥,用于回调通知的解密。
  • 商户 API 证书:包含 apiclient_cert.pem 和 apiclient_key.pem,v3 接口签名和敏感信息解密都要用。

还有一个动作必须做:在开放平台把 Android 应用签名填进去。获取方式很简单,用 keytool 就行:

keytool -list -v -keystore your_release.jks -alias your_alias

输出里会有一行 MD5 指纹,形如AB:CD:EF:...。把它去掉冒号、全部转成小写,就是微信要的“应用签名”。很多人只取了 SHA1,填进去怎么都不对,这个坑非常隐蔽。

提示:Debug 包和 Release 包的签名不同,开放平台只能填一个。如果测试阶段用的是 debug 包,那开放平台就得填 debug 的签名,正式发包前记得换回来,否则线上必然调不起。

2.2 Android Studio 工程配置:包名、签名、混淆

工程侧的配置看起来琐碎,但每一项都和后面的报错直接挂钩。

包名(applicationId)在微信那边是认死了的。你注册移动应用时填的包名是什么,APK 里的 applicationId 就必须是什么,一个字都不能差。改包名这种事在接入支付之后就别想了,要改就得回开放平台重新提审。

签名配置建议用 build.gradle 里的 signingConfigs 管理,别用 Android Studio 自带的“Generate Signed Bundle”手动打包,那种方式容易在不同机器上产出不同的签名。

android { signingConfigs { release { storeFile file("../keystore/release.jks") storePassword System.getenv("KS_PWD") keyAlias "release" keyPassword System.getenv("KEY_PWD") } } buildTypes { release { signingConfig signingConfigs.release minifyEnabled true proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } } }

混淆规则里必须给微信 SDK 留口子。微信的 SDK 里用了反射和大量回调类,混淆之后回调直接进不来,表现就是“支付完成了但我的页面没反应”。稳妥的做法是加一条最宽的规则:

-keep class com.tencent.mm.opensdk.** { *; } -keep class com.tencent.wxop.** { *; } -keep class com.tencent.mm.sdk.** { *; }

至于那些所谓“支付代币数量支持小数点吗”之类的疑问,本质上都是金额精度问题。微信支付的 total_fee 单位是,类型是整数,压根不存在小数。你想收 9.9 元,传的就是 990。任何在服务端用浮点数做金额运算的写法都是隐患,0.1 + 0.2 这种经典问题在订单系统里会变成一分钱的账目不平。统一的处理方式是:数据库里金额存整数分,展示时除以 100,运算全程用整数或 BigDecimal

2.3 微信 SDK 引入与 WXEntryActivity 的注册细节

SDK 的引入方式有两种,早期的 jar 包和现在的 Maven 依赖。现在建议直接用 Maven,版本更新更省事:

implementation 'com.tencent.mm.opensdk:wechat-sdk-android:6.8.0'

引入之后有两个必做动作,漏掉任何一个支付都调不起来。

第一个是WXPayEntryActivity。这个类的路径是死的,必须是<你的包名>.wxapi.WXPayEntryActivity。注意.wxapi这一层小写,类名大小写也要对。它不是普通的 Activity,微信 App 支付完成后会直接按这个约定路径来找你的回调入口。这个名字写错了,微信找不到入口,回调就永远不会触发。

<activity android:name=".wxapi.WXPayEntryActivity" android:exported="true" android:launchMode="singleTop"> <intent-filter> <action android:name="android.intent.action.VIEW" /> <category android:name="android.intent.category.DEFAULT" /> <data android:scheme="wx你的AppID" /> </intent-filter> </activity>

这里有个容易踩的细节:android:exported必须显式写成 true。Android 12 之后如果不写这个属性,编译期直接报错;就算编译过了,微信也进不来。另外launchMode建议给 singleTop,避免用户连续操作时出现多个实例,回调重复触发。

第二个是 Android 11(API 30)之后的包可见性。系统默认不允许应用随便查询其他应用是否安装,而微信 SDK 内部会去判断微信有没有装。不声明的话,api.isWXAppInstalled()永远返回 false,用户明明装了微信却提示“请先安装微信”。

<queries> <package android:name="com.tencent.mm" /> </queries>

这段写在 manifest 的顶层,和 application 平级。另外如果调起参数里带了package字段,正常情况下微信会直接用这个字段去匹配,不需要你在代码里手动 setPackage。但有些机型上如果不设,会出现选错应用的情况,稳妥起见可以在 sendReq 之前加一句req.package = "Sign=WXPay",注意这里赋的是字符串本身。

3. 服务端下单接口:预支付订单生成的核心细节

3.1 统一下单的参数清单与常见错误值

服务端这一侧是整个流程的心脏。以 APIv2 的统一下单接口https://api.mch.weixin.qq.com/pay/unifiedorder为例,必填参数其实不多,但每个都有讲究:

参数名是否必填说明容易出错的地方
appid开放平台移动应用 AppID误填公众号 AppID
mch_id商户号多商户号时选错
nonce_str32 位内随机字符串用固定值,被风控
body商品描述含特殊字符导致签名不一致
out_trade_no商户订单号32 字符内,重复会报错
total_fee总金额,单位分传了小数或元
spbill_create_ip终端 IP传了内网 IP 或空值
notify_url异步通知地址用了 http 或外网不可达
trade_type固定 APP误填 JSAPI
sign签名字符串大小写、编码问题

out_trade_no这个字段值得单独说。它的规则是同一个商户号下必须唯一,重复提交会直接返回错误。有些项目用时间戳生成订单号,秒级并发下就会撞车;用 UUID 又太长超过 32 字符。我的做法是“日期 + 自增序列 + 随机后缀”,比如20240517153000加上几位随机数,既可控又不会超长。

body字段看起来最无害,实际上最容易翻车。如果商品名里带了&=、中文标点,签名拼接的时候就会错位。我一般会在服务端把 body 做一次清洗,只保留中文、字母、数字和常用符号。

注意:notify_url 必须是公网可直接访问的地址,不能带参数,不能是内网 IP,端口只支持 80 和 443。用测试环境的内网地址去下单,表现是订单能创建成功,但异步通知永远收不到,排查起来非常费时间。

3.2 签名算法:MD5、HMAC-SHA256 与 APIv3 的区别

签名是新手最头疼的部分,也是报错最多的地方。APIv2 的签名逻辑其实就四步:

  1. 把所有非空参数按参数名的 ASCII 码从小到大排序,拼成key=value&key=value的形式,注意末尾不加&key=
  2. 在拼好的字符串末尾拼接&key=你的API密钥
  3. 对整个字符串做 MD5,得到 32 位小写字符串。
  4. 转成大写,作为 sign 字段。

用 Python 表达就是这几行:

import hashlib def build_sign(params: dict, api_key: str) -> str: items = [(k, v) for k, v in params.items() if v is not None and v != "" and k != "sign"] items.sort(key=lambda x: x[0]) raw = "&".join(f"{k}={v}" for k, v in items) raw = f"{raw}&key={api_key}" return hashlib.md5(raw.encode("utf-8")).hexdigest().upper()

这里有几个魔鬼细节。第一,空值参数不参与签名,但如果你传了空字符串又参与了签名,微信那边算出来的结果就不一样。第二,编码必须是 UTF-8,用 GBK 编码算出来的 MD5 完全是另一个值。第三,大小写。APIv2 的 sign 要求大写,很多人算出小写直接扔过去,微信返回“签名错误”,然后对着代码看半天。

如果选择 HMAC-SHA256 签名方式,前两步完全一样,只是第三步换成用 API 密钥做 HMAC 计算,结果转小写。要注意的是,签名方式是在下单时通过 sign_type 字段指定的,而且它参与签名本身

APIv3 是另一套体系,安全性高不少。它不用 MD5,而是用商户私钥做 SHA256withRSA 签名,请求头里带Authorization: WECHATPAY2-SHA256-RSA2048 ...,签名串由 HTTP 方法、URL、时间戳、随机串、请求体拼接而成。回调通知则用微信平台证书公钥验签,再用 APIv3 密钥做 AES-256-GCM 解密。看起来复杂,但好处是不会因为一个字符串排序问题就全军覆没。新项目我建议直接上 v3,v2 更像是历史包袱。

3.3 返回 prepay_id 之后要做什么

下单成功的响应里,最有价值的就是prepay_id。但千万别把这个值直接丢给客户端。客户端需要的是二次签名后的一整包参数:

{ "appid": "wx1234567890", "partnerid": "1900000109", "prepayid": "wx17160000000000000000000000", "package": "Sign=WXPay", "noncestr": "5K8264ILTKCH16CQ2502SI8ZNMTM67VS", "timestamp": "1716000000", "sign": "二次签名结果" }

注意这里的package是固定值Sign=WXPay,不是包名,也不是订单号。这个字段迷惑性极强,我第一次接的时候还以为是 APK 的包名,填了 applicationId 进去,结果报 -1。

二次签名的规则和下单签名类似:把这几个参数(appid、partnerid、prepayid、package、noncestr、timestamp)按字典序排列,拼接 API 密钥,MD5 后转大写。客户端拿到的这包参数是明文的,但 sign 保证了它没法被篡改,因为改任何一个字段签名就对不上。

timestamp这里要特别注意类型。服务端生成的时候是秒级字符串,客户端拼进请求对象时是 long。Java 里req.timeStamp = "1716000000"是可以的但从 SDK 6.8 之后更推荐用 long,如果版本对不上会直接报参数错误。这个坑在不同 SDK 版本之间表现不一样,建议以你实际引入的版本为准,先在测试环境跑通再上线。

4. 客户端调起支付与结果处理的完整实现

4.1 一个 IWXAPI 实例贯穿全局

客户端这一侧,第一个要解决的问题是 SDK 的初始化。这里有个很常见的错误做法:在需要支付的 Activity 里临时WXAPIFactory.createWXAPI,支付完就不管了。这样做会导致onResp回调找不到归属,或者回调时序错乱。

正确的做法是全局单例,通常在 Application 里初始化一次:

object WxPayManager { private var api: IWXAPI? = null fun init(context: Context, appId: String) { if (api == null) { api = WXAPIFactory.createWXAPI(context, appId, true) } api?.registerApp(appId) } fun pay(req: PayReq): Boolean { val api = api ?: return false if (!api.isWXAppInstalled) return false if (!api.isWXAppSupportAPI) return false return api.sendReq(req) } }

这个单例里有两个判断必须做。isWXAppInstalled判断微信是否安装,没装的话要给用户提示而不是直接调起。isWXAppSupportAPI判断微信版本是否支持当前 SDK 的接口,老版本微信可能在 sendReq 时直接返回 false。

还有一个经常被忽略的点:registerApp 的调用时机。它需要在发送请求之前完成,而且只需要调一次。有些项目在每次支付前都 registerApp 一遍,虽然不算错,但在某些定制 ROM 上会出现注册状态被重置,导致第一次 pai 返回 false、第二次才成功。统一在 Application 里注册就规避了这个问题。

提示:初始化用的 AppID 必须和下单时服务端用的 AppID 完全一致。曾经遇到一个项目,测试环境用的是 A 商户的 AppID,服务端配置的是 B 商户,结果客户端能调起微信,微信那边直接提示“商户参数错误”。排查了整整一个下午。

4.2 调起参数的拼装与时间戳陷阱

拿到服务端返回的参数之后,组装 PayReq 就可以了:

val req = PayReq().apply { appId = params.appid partnerId = params.partnerid prepayId = params.prepayid packageValue = params.package // 注意是 packageValue nonceStr = params.noncestr timeStamp = params.timestamp sign = params.sign } WxPayManager.pay(req)

这里有个命名上的小坑:PayReq 里对应package的字段名是packageValue,因为 package 在 Java 里是关键字。用 Kotlin 写的时候req.packageValue = "Sign=WXPay"是对的,写成req.package编译不过。这个错误很蠢但真的有人卡在这里。

timeStamp的类型在不同 SDK 版本里有差异。老版本 SDK 里是 String,新版本改成了 long。如果你的 Gradle 里依赖版本比较老,写timeStamp = "1716000000"是对的;升级到 6.8 之后写字符串会被编译器拒绝。处理办法很简单,看看编译报错就知道当前版本要的是什么类型,或者干脆把时间戳统一用 long 从服务端传过来。

时间戳还有一个隐藏问题:它和 prepay_id 的有效期绑定。微信统一下单返回的 prepay_id 有效期是两小时,但真正调起支付时,微信会校验时间戳与服务器时间的偏差。如果客户端本地时间被用户改过,或者时区设置异常,会出现“支付参数过期”的提示。我的做法是:从下单到调起之间的时间间隔尽量短,最好在几十秒内完成;如果用户在收银台界面停留太久才点确认,也建议重新走一次下单流程,拿到新的 prepay_id 再调起。

4.3 WXPayEntryActivity 回调的分支处理与幂等

回调处理是客户端逻辑的重头戏。前面注册的WXPayEntryActivity里,onResp会收到resultCode,一共三种:

resultCode含义客户端该做什么
0支付成功提示用户,通知服务端查询订单状态
-1支付失败/错误提示失败,允许重试,带上 errCode 便于排查
-2用户主动取消静默返回,不做任何提示或轻提示

很多人把 0 当成“钱已经到账”,直接跳转成功页。这里必须强调一遍:resultCode 等于 0 只代表用户完成了支付动作,不代表你的服务端已经收到钱。真正的到账确认要靠服务端收到异步通知。所以客户端的正确姿势是:收到 0 之后,向自己的服务端发起一个“查询订单状态”的请求,服务端返回已支付才跳成功页。

override fun onResp(resp: BaseResp) { if (resp.type != ConstantsAPI.COMMAND_PAY_BY_WX) return when (resp.errCode) { 0 -> queryOrderFromServer() -1 -> toast("支付失败,请重试") -2 -> { /* 用户取消,什么都不做 */ } } } private fun queryOrderFromServer() { // 带上 out_trade_no 请求自己的服务端 // 服务端返回已支付 -> 跳成功页 // 返回未支付 -> 轮询几次,仍然未支付则提示"支付结果确认中" }

这段代码里有个很重要的容错设计:轮询。因为微信的异步通知和服务端的处理都有延迟,用户刚支付完的一两秒内,你的订单状态可能还是“待支付”。如果这时候直接提示失败,用户体验会很差。我一般会轮询三到五次,每次间隔一秒,实在查不到就提示“支付结果确认中,请稍后在订单列表查看”。

另外,WXPayEntryActivity的 onResp 回调可能会重复触发,尤其是 launchMode 配置不对的时候。所以在处理里要做幂等,比如用一个标记位挡住重复的跳转,或者在 onResp 之后立刻 finish 掉当前 Activity。

4.4 客户端结果永远不可信:服务端异步通知才是准绳

异步通知(notify_url)这一环是整条链路里唯一可信的数据源。它的处理逻辑应该长这样:

@app.route("/wxpay/notify", methods=["POST"]) def wxpay_notify(): raw = request.data.decode("utf-8") # 1. 验签,确认是微信发的 if not verify_sign(raw): return '<xml><return_code><![CDATA[FAIL]]></return_code></xml>' # 2. 解析参数 data = parse_xml(raw) # 3. 校验金额,防止被篡改 order = find_order(data["out_trade_no"]) if order.total_fee != int(data["total_fee"]): return 'FAIL' # 4. 幂等处理:已处理过的直接返回成功 if order.status == "PAID": return 'SUCCESS' # 5. 改状态、发货、记录日志 mark_paid(order) return '<xml><return_code><![CDATA[SUCCESS]]></return_code></xml>'

这里有几个必须做的动作。验签是第一道防线,没有验签的处理接口等于开放给全世界。金额校验是第二道,防止有人伪造一笔小额订单的通知来骗你的商品。幂等是第三道,微信的通知机制是“至少一次”,同一笔订单可能收到多次通知,如果没有幂等,你的发货逻辑就会重复执行。

注意:收到通知后必须返回 SUCCESS 的 XML,否则微信会按照 15 秒、15 秒、30 秒、3 分钟、10 分钟、20 分钟、30 分钟、30 分钟、30 分钟、60 分钟、3 小时、3 小时、3 小时、6 小时、6 小时的节奏反复通知,一直持续 24 小时。如果问题出在你自己这边,赶紧修;如果只是暂时处理不过来,先把成功返回给微信,自己内部再补偿。

5. 上线后最容易炸的几个地方:排查清单与实操心得

5.1 常见错误码速查表与定位思路

微信支付报错的时候,日志信息往往很吝啬,一个 -1 什么都不告诉你。下面这张表是我这些年攒下来的对应关系,基本能覆盖八成的问题:

现象大概率原因定位方法
调起微信立刻返回 -1应用签名不匹配对比开放平台签名与 keytool 输出
调起微信立刻返回 -1AppID 与商户号不匹配核对服务端与客户端 AppID
提示"商户参数错误"prepay_id 无效或过期重新下单,检查下单参数
微信界面闪一下就返回 -2用户主动取消正常,无需处理
支付完成后没有回调WXPayEntryActivity 路径错误检查类名与包名是否严格一致
支付完成后没有回调混淆把回调类混淆了检查 proguard 规则
isWXAppInstalled 返回 falseAndroid 11 未声明 queries添加 package 声明
下单返回"签名错误"拼接顺序或大小写问题打印原始签名串比对
下单返回"订单号重复"out_trade_no 不唯一检查订单号生成规则
异步通知收不到notify_url 外网不可达用在线工具从公网探测

排查这类问题的核心思路是分段隔离。先确认下单接口能不能通,把服务端日志打出来,看微信返回的原始响应是什么;下单通了之后再看调起,把客户端拿到的参数完整打印出来,和微信文档里的示例逐字段对比;调起通了再看回调,确认 WXPayEntryActivity 有没有被正确加载,可以在这个类的 onCreate 里打一行日志,如果连这行日志都没出现,那就是路径或者 manifest 的问题。

5.2 包名、签名与"能调起但支付失败"的组合问题

有一种非常典型的现象:微信能被正常调起,收银台也弹出来了,但用户一确认支付就报错,回调 -1,而 errCode 里也没有更多信息。这种“半通不通”的状态,八成是包名或签名的问题。

微信在调起支付时会做一次校验:请求里带的 AppID 对应的移动应用,其注册的包名和签名,必须和你当前运行的 APK 一致。不一致就直接拒。这里有几个容易出错的地方:

第一,多渠道包。如果你用 productFlavors 打出了多个不同 applicationId 的包,但开放平台只注册了主包名,那么其他渠道包全部支付失败。解决方式是所有渠道包共用同一个 applicationId,只在渠道标识上做区分。

第二,测试包和正式包。前面说过,开放平台只能填一个签名。如果测试阶段填的是 debug 签名,正式包用 release 签名就必然失败。我的做法是本地测试也统一用 release 签名,通过 signingConfigs 配置,这样开发和线上环境完全一致,避免最后关头才发现问题。

第三,加固之后签名变化。有些加固平台会在加固后重新签名,如果用的是他们提供的签名,那就和开放平台填的对不上了。加固之后务必重新取一次签名 MD5,和开放平台的配置核对一遍。

第四,微信缓存。微信客户端会缓存应用的签名和包名信息,有时候你更新了开放平台配置,微信那边要过一段时间才刷新。可以在手机上清除微信缓存,或者用微信的“开发者调试”功能强制刷新。实测下来,清除缓存后重启微信,成功率最高。

5.3 金额、精度与那些绕不开的边界情况

金额这个话题看起来简单,实际上边界情况不少。

金额单位是分,这个已经说过。但还有一些衍生问题。比如一分钱支付的测试:有些项目为了测试方便,会把金额写死成 1 分,上线时忘了改,用户花一分钱买走了商品。这种事故听起来离谱,但每年都能听到几例。我的建议是在服务端做一层校验,如果商品的价格配置和下单金额不一致,直接拒绝下单并报警。

退款金额的精度同样要注意。部分退款时,退款金额必须小于等于订单金额,而且多次退款的总额不能超过原订单。如果用浮点数累加退款金额,很容易出现99.99 != 100.00这种问题,导致最后一次退款失败。统一用整数分计算,这类问题自然消失。

优惠券与实付金额的关系也需要提前想清楚。如果有满减、折扣,total_fee 应该传用户实际支付的金额,而不是商品原价。对账的时候微信那边的金额就是实付金额,如果传成了原价,账目会对不上。

还有一类边界是超时订单。微信统一下单支持设置time_expire参数,超时之后这个 prepay_id 就失效了。如果用户在你的 App 里点开支付页,放置两个小时后再点支付,就会失败。体验更好的做法是在客户端做倒计时,快到期时提示用户“订单即将失效,请重新下单”。

5.4 上线前我必做的检查清单

吃了不少亏之后,我给自己整理了一份上线前的固定检查项,每次接入支付都过一遍:

  • 开放平台的应用签名,是否和 release 包一致
  • 服务端的 API 密钥,是否和商户平台一致,是否放在环境变量里而不是代码里
  • notify_url 是否公网可达,是否用了 https
  • 异步通知的处理接口是否有验签、金额校验、幂等
  • 客户端 onResp 里的 0 分支,是否走的是服务端查询而不是直接标成功
  • 混淆规则是否包含微信 SDK
  • Android 11 的 queries 声明是否加上
  • WXPayEntryActivity 的路径和 exported 属性是否正确
  • 订单号生成规则在并发下是否会重复
  • 是否有对账和补单机制

这份清单看起来啰嗦,但每一项背后都有真实的事故案例。尤其是倒数第二项和最后一项,前者决定你上线首日会不会被客服电话淹没,后者决定你在出问题时能不能快速兜底。

6. 订单生命周期的延伸:查询、退款与对账

6.1 主动查询与补偿任务的实现

异步通知并不是百分之百可靠。网络抖动、服务器重启、部署期间的通知丢失,都可能让订单卡在“待支付”状态。所以必须有一个补偿机制。

最常见的做法是定时任务轮询。每隔几分钟,扫一遍创建时间在 30 分钟内、状态还是“待支付”的订单,调用微信的订单查询接口(APIv2 是/pay/orderquery,v3 是/v3/pay/transactions/out-trade-no/{out_trade_no}),如果查到已支付,就补上状态和发货逻辑。

def compensate_orders(): orders = query_db( "select * from orders where status='PENDING' " "and created_at > now() - interval 30 minute" ) for order in orders: result = wx_query(order.out_trade_no) if result["trade_state"] == "SUCCESS": mark_paid(order) deliver(order)

这个任务的参数要调好。扫描范围太长会浪费请求次数,太短又容易漏。30 分钟到 2 小时是我觉得比较合适的区间,因为微信订单的支付有效期一般也是这个量级。另外,这个任务必须是幂等的,和异步通知共用同一套mark_paid逻辑,避免两条路走出来的状态不一样。

查询接口还有一个用途是排障。用户打电话说“我明明付了钱但订单还是待支付”,你拿订单号去查一下,trade_state 是 SUCCESS 还是 NOTPAY,一目了然。如果查到 SUCCESS 但你的库里还是待支付,那就是通知丢失了,手动补一下状态即可。

6.2 退款流程与对账文件的处理

退款是另一条独立的链路。调用退款接口需要用到商户 API 证书,这一点和下单不一样,v2 退款和 v3 退款都要求证书认证。所以证书文件要妥善保管,并且配置好路径。

退款接口的关键参数是out_refund_no(商户退款单号)和out_trade_no(原订单号)。退款单号也需要唯一,规则和下单的订单号类似。退款是异步的,提交成功只代表请求被接受,真正到账要等一段时间,所以退款也需要查状态和对账。

对账这块,微信提供的是对账单下载接口,按天生成,包含当天的所有交易明细。我的做法是每天凌晨拉取前一天的对账单,和自己的订单表做比对,找出三类差异:微信有我没有的(可能是通知全丢了)、我有微信没有的(可能是伪造的订单)、金额不一致的(得重点排查)。这三类差异处理完,账目基本就平了。

对账单文件是压缩包,下载后需要解压、解析、入库。文件格式是 CSV,字段包括交易时间、商户订单号、微信订单号、金额、手续费等。用 Python 的 csv 模块几行就能处理,不需要引入额外依赖。

6.3 支付超时与库存回滚的联动

最后说说库存。电商场景下,用户下单会先锁库存,如果支付超时了,库存要还回去。这个逻辑和支付状态是联动的。

我的做法是:订单创建时就写入一个expire_at字段,同时通过time_expire参数告知微信这个订单的有效期。定时任务除了补偿支付状态,还负责处理超时订单——把状态改为“已关闭”,同时把占用的库存加回去。

这里要注意顺序。先关闭订单,再还库存。如果反过来,先还了库存,这时用户恰好支付成功了,你就要面对“库存已经还了但钱收了”的尴尬局面。正确处理是:把订单标记为关闭之后,再调微信的关单接口(v3 的/v3/pay/transactions/out-trade-no/{}/close),如果关单失败说明用户已支付,就走支付成功的逻辑。这样就不会两头打架。

还有一个细节是超时时间的一致性。客户端展示的倒计时、服务端的 expire_at、微信的 time_expire,这三个要尽量对齐,最好都以服务端的时间为准。如果客户端本地时间不准,倒计时会显示得乱七八糟,用户也会困惑。


我自己在这些项目里最大的体会是,微信支付的代码量其实不大,难的是把每一个环节的边界都想清楚。下单、调起、回调、通知、查询、退款、对账,每一环都有它自己的失败可能,而支付这件事对失败的容忍度极低。所以别急着写代码,先把时序图画出来,把每个环节的输入输出和失败处理列出来,后面敲键盘的时候会顺畅很多。还有一个小心得是:测试阶段一定要用真机、真微信、真商户号跑通全流程,模拟器上的表现和真机差别很大,尤其是调起和回调这一块,模拟器上跑通了不代表真机没问题。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询