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 | 公众号网页、微信内 H5 | WeixinJSBridge | 需公众号 + 授权域名 |
| Native 扫码 | NATIVE | PC 网站、收银台大屏 | 返回二维码链接 | 需 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_str | 是 | 32 位内随机字符串 | 用固定值,被风控 |
| 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 的签名逻辑其实就四步:
- 把所有非空参数按参数名的 ASCII 码从小到大排序,拼成
key=value&key=value的形式,注意末尾不加&key=。 - 在拼好的字符串末尾拼接
&key=你的API密钥。 - 对整个字符串做 MD5,得到 32 位小写字符串。
- 转成大写,作为 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 输出 |
| 调起微信立刻返回 -1 | AppID 与商户号不匹配 | 核对服务端与客户端 AppID |
| 提示"商户参数错误" | prepay_id 无效或过期 | 重新下单,检查下单参数 |
| 微信界面闪一下就返回 -2 | 用户主动取消 | 正常,无需处理 |
| 支付完成后没有回调 | WXPayEntryActivity 路径错误 | 检查类名与包名是否严格一致 |
| 支付完成后没有回调 | 混淆把回调类混淆了 | 检查 proguard 规则 |
| isWXAppInstalled 返回 false | Android 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,这三个要尽量对齐,最好都以服务端的时间为准。如果客户端本地时间不准,倒计时会显示得乱七八糟,用户也会困惑。
我自己在这些项目里最大的体会是,微信支付的代码量其实不大,难的是把每一个环节的边界都想清楚。下单、调起、回调、通知、查询、退款、对账,每一环都有它自己的失败可能,而支付这件事对失败的容忍度极低。所以别急着写代码,先把时序图画出来,把每个环节的输入输出和失败处理列出来,后面敲键盘的时候会顺畅很多。还有一个小心得是:测试阶段一定要用真机、真微信、真商户号跑通全流程,模拟器上的表现和真机差别很大,尤其是调起和回调这一块,模拟器上跑通了不代表真机没问题。