
1. 一条支付链路里Android 客户端其实只干三件事前阵子帮一个做社区团购的朋友排查线上支付问题日志里onResp回调的errCode稳定返回 -1但服务端查单显示订单状态是已支付。他们团队的判断是微信 SDK 有问题改了两天代码没结果。最后我让他们去商户平台看了一眼 AppID 绑定关系发现是运营在新版开放平台上重新建了一个应用AppID 换了但服务端配置文件没跟着更新客户端签的名和商户号对不上微信侧自然报错。这类事情在 Android 微信支付接入里太常见了。很多教程一上来就贴代码读者抄完能跑通 Demo一到真机就各种 errCode 乱飞根本原因是没搞清楚这条链路上每个角色各自要承担什么责任。微信支付 APP 支付本质上是三方协作商户 App、商户服务端、微信支付系统再算上用户手机里的微信客户端就是四方。Android 端在整个流程里的职责其实很轻轻到你可以用三句话讲完。第一件事是注册。App 启动时通过开放平台 SDK 把 AppID 告诉微信客户端让微信知道有这么个应用请求跟我通信。这一步失败得比较隐蔽因为它不会立刻报错而是等到你调起支付时才以参数错误的形式暴露出来。第二件事是调起。拿到服务端算好的prepayId、nonceStr、timeStamp、sign等字段后组装成一个PayReq对象通过IWXAPI.sendReq()交给微信客户端。此时 Android 端的活儿基本就干完了接下来是微信内部的收银台界面、密码/指纹验证、扣款这些全部由微信客户端自己完成商户 App 无权干预。第三件事是接收回调。用户在微信里点了确认或者取消之后微信客户端会把结果通过一个约定好的WXPayEntryActivity回传给商户 App。注意这里的关键词是回传不是通知。这个 Activity 拿到的只是一个前端提示性的结果真正决定这笔钱该不该发货的判断依据应该来自微信服务器向商户服务器推送的异步通知。我见过太多团队把第三件事和发货逻辑绑在一起。App 收到errCode 0就直接给用户加积分、发券、开会员结果遇到网络抖动或者用户直接杀进程回调丢了钱扣了但东西没给客服工单立刻堆满。记住这条铁律客户端回调只负责 UI 展示发货永远以服务端异步通知加主动查单为准。后面我会专门用一节讲这个。角色核心职责关键产出商户 Android App注册 SDK、组装 PayReq、调起支付、接收前端回调一次sendReq调用、一个onResp回调商户服务端调统一下单接口、做二次签名、接收异步通知、发货prepayId、paySign、订单状态机微信支付系统生成预支付订单、扣款、推送异步通知交易单号、支付结果微信客户端展示收银台、验证支付密码、把结果回传一个 Intent 跳转回来的结果这张表建议贴在工位上每次出问题先对一下是哪一列出了岔子能省掉八成无效排查。2. 动手写代码之前先在这些平台上把账对平写代码是最简单的一步真正耗时间的是资质和配置。我按实际顺序把准备工作捋一遍顺序错了会返工。2.1 微信开放平台拿到 AppID 并完成移动应用审核去微信开放平台创建移动应用填写应用名称、包名、应用签名、应用图标等信息。关键是包名和应用签名必须和你最终上线包完全一致这里有一点出入后面调起支付时就会出现经典的errCode -1。应用提交审核通过后会拿到一个 AppID形如wx开头的 18 位字符串。开发阶段可以先用测试应用或者用自己的账号申请但上线前必须换成正式审核通过的 AppID。有一个细节很多人忽略开放平台上的应用签名填的是签名文件的 MD5 值去掉冒号并且全部小写。不是 SHA1不是 SHA256就是 MD5。这个值随签名文件变化debug 包和 release 包的签名不一样所以两个值都要配或者干脆让团队统一用同一个签名文件调试。2.2 商户平台mch_id、API 密钥与 AppID 绑定在微信支付商户平台申请开通 APP 支付拿到商户号mch_id。然后设置 API 密钥——注意微信支付有两套密钥体系APIv2 密钥32 位字符串用于 MD5 / HMAC-SHA256 签名老接口和相当一部分存量系统还在用。APIv3 密钥同样 32 位用于 SHA256-RSA 签名体系配套还有商户 API 证书。新项目建议直接上 APIv3安全性和接口设计都更规范。但如果你的服务端框架、SDK 或者历史代码是围绕 APIv2 建的混用会非常痛苦一个项目里只选一套别两边都沾。最关键的一步是在商户平台把 AppID 和商户号关联起来。路径大致是产品中心 - APP支付 - 关联 AppID。没有这层关联统一下单能成功返回prepayId但客户端调起时会报错而且报错信息相当含糊能让你查一整天。2.3 应用签名的获取方式命令行方式用 JDK 自带的 keytoolkeytool -list -v -keystore release.jks -alias your_alias -storepass your_password | grep MD5输出类似MD5: 3A:5B:...把冒号去掉、转小写就是你要填的值。如果是通过 Android Studio 打包也可以直接在 Gradle 的signingConfig里配好签名然后用代码在运行时打印public static String getSignatureMd5(Context context) { try { PackageInfo info context.getPackageManager() .getPackageInfo(context.getPackageName(), PackageManager.GET_SIGNATURES); Signature[] signatures info.signatures; MessageDigest md MessageDigest.getInstance(MD5); md.update(signatures[0].toByteArray()); byte[] digest md.digest(); StringBuilder sb new StringBuilder(); for (byte b : digest) { String hex Integer.toHexString(b 0xFF); if (hex.length() 1) sb.append(0); sb.append(hex); } return sb.toString(); } catch (Exception e) { return ; } }把打印出来的值和开放平台后台的值对一下不一致就说明你当前跑的这个包用的签名文件不是配的那个。这是我最常用的一招比翻配置快得多。2.4 工程侧的依赖与最低配置微信官方 SDK 现在统一在 Maven Central 上Gradle 依赖写dependencies { implementation com.tencent.mm.opensdk:wechat-sdk-android:6.8.0 }老教程里常见wechat-sdk-android-without-mta和wechat-sdk-android-with-mta两个包名那是几年前的分支新项目直接用上面这个即可。建议锁定具体版本号不要用否则某天构建时拉到一个新版 SDK 引入行为变化你会莫名其妙地复现不出来问题。minSdkVersion方面SDK 本身对低版本兼容还行但考虑到现在 Android 11 以上的包可见性限制建议minSdkVersion至少设到 21targetSdkVersion跟着 Google Play 的要求走。3. 服务端统一下单prepayId 到底是怎么算出来的这部分严格说不属于 Android 端但如果你不理解客户端代码抄得再对也没用因为报错信息全指向客户端。3.1 参数排序、拼串、加盐这三步不能错微信支付的签名规则十几年没变过核心就三条把所有非空参数按参数名 ASCII 码从小到大排序用keyvalue的形式用拼成一个字符串末尾拼上key你的API密钥然后做 MD5或 HMAC-SHA256结果转大写。坑点集中在几个地方。值为空的参数不参与签名也不能出现在请求体里。有些人为了省事把attach、detail这些可选字段写成空字符串塞进去签名立刻失败。sign字段本身不参与签名签完再放。所有字符串统一用 UTF-8 编码尤其是商品名里有中文的时候。还有一个特别容易被忽略的金额单位是分。1 元要传100。这个低级错误每年都会在新项目里出现因为它不会报错只会让你的用户花 100 块买到本该 1 块的东西。3.2 一段可直接用的下单代码用 Java 写一个最小可用的版本字段名和顺序都是标准的private static final String APP_ID wxxxxxxxxxxxxxxxx; private static final String MCH_ID 1230000109; private static final String API_KEY 32位APIv2密钥; public static String sign(MapString, String params, String key) throws Exception { ListString keys new ArrayList(params.keySet()); Collections.sort(keys); StringBuilder sb new StringBuilder(); for (String k : keys) { String v params.get(k); if (v null || v.isEmpty() || sign.equals(k)) continue; sb.append(k).append().append(v).append(); } sb.append(key).append(key); MessageDigest md MessageDigest.getInstance(MD5); byte[] digest md.digest(sb.toString().getBytes(StandardCharsets.UTF_8)); StringBuilder hex new StringBuilder(); for (byte b : digest) { hex.append(String.format(%02X, b)); } return hex.toString(); } public static MapString, String createAppOrder(String outTradeNo, int totalFee, String body, String notifyUrl, String clientIp) throws Exception { MapString, String p new HashMap(); p.put(appid, APP_ID); p.put(mch_id, MCH_ID); p.put(nonce_str, randomStr(32)); p.put(body, body); p.put(out_trade_no, outTradeNo); p.put(total_fee, String.valueOf(totalFee)); p.put(spbill_create_ip, clientIp); p.put(notify_url, notifyUrl); p.put(trade_type, APP); p.put(sign, sign(p, API_KEY)); String xml toXml(p); // POST 到 https://api.mch.weixin.qq.com/pay/unifiedorder // 解析返回的 XML取 prepay_id return parseXml(post(https://api.mch.weixin.qq.com/pay/unifiedorder, xml)); }trade_type必须写APPnotify_url必须是公网可直接访问的 HTTPS 地址路径里不要带查询参数。out_trade_no商户订单号限 6 到 32 位只允许数字、大小写字母和_-|*且必须全局唯一——重复的订单号会直接返回错误。3.3 二次签名客户端要的字段和服务端拿到的不是一套统一下单成功后微信返回prepay_id它的有效期默认两小时。但这个prepay_id不能直接给客户端用必须再做一次签名。二次签名的参数集合变了是这六个appidpartnerid就是 mch_idprepayidpackage固定值SignWXPaynoncestrtimestamp秒级同样按 ASCII 排序拼串加key做 MD5 转大写得到的就是客户端PayReq.sign要填的值。public static MapString, String buildPayParams(String prepayId) throws Exception { MapString, String p new HashMap(); p.put(appid, APP_ID); p.put(partnerid, MCH_ID); p.put(prepayid, prepayId); p.put(package, SignWXPay); p.put(noncestr, randomStr(32)); p.put(timestamp, String.valueOf(System.currentTimeMillis() / 1000)); p.put(sign, sign(p, API_KEY)); return p; }把这张 map 直接序列化成 JSON 返回给客户端客户端逐个字段取出来填进PayReq不要做任何加工——包括不要自己重算timestamp不要改nonceStr的大小写不要给prepayId加引号或者去空格。服务端签的是什么客户端就必须原封不动地传什么这是签名机制的本质。4. 客户端调起支付PayReq 七个字段的人和事4.1 registerApp 的调用时机和多进程的坑registerApp我建议放在自定义Application的onCreate里全局只调一次public class App extends Application { public static final String APP_ID wxxxxxxxxxxxxxxxx; private static IWXAPI api; Override public void onCreate() { super.onCreate(); api WXAPIFactory.createWXAPI(this, APP_ID, true); api.registerApp(APP_ID); } public static IWXAPI getApi() { return api; } }如果你的 App 有多个进程比如推送、播放器、WebView 沙箱各占一个进程Application.onCreate会在每个进程里都执行一遍。这时候要注意别在里面做重逻辑registerApp本身是轻量的但如果你在同一处还做了数据库初始化、统计上报多进程会跑多次出问题很难查。WXAPIFactory.createWXAPI(context, appId, checkSignature)的第三个参数在新版 SDK 里已经没什么实际作用了传true或者省掉都行别在这上面纠结。4.2 调起支付的完整代码public void pay(PayParams params) { IWXAPI api App.getApi(); if (!api.isWXAppInstalled()) { Toast.makeText(this, 未检测到微信客户端, Toast.LENGTH_SHORT).show(); return; } if (api.getWXAppSupportAPI() Build.PAY_SUPPORTED_SDK_INT) { Toast.makeText(this, 微信版本过低请升级后再试, Toast.LENGTH_SHORT).show(); return; } PayReq req new PayReq(); req.appId params.appid; req.partnerId params.partnerid; req.prepayId params.prepayid; req.packageValue params.packageValue; // SignWXPay req.nonceStr params.noncestr; req.timeStamp params.timestamp; req.sign params.sign; boolean ret api.sendReq(req); if (!ret) { Toast.makeText(this, 调起支付失败请稍后重试, Toast.LENGTH_SHORT).show(); } }sendReq返回false几乎只有两种原因一是PayReq里有字段为空SDK 在本地校验就拦下了二是appId和registerApp时用的不一致。这两种情况都不会弹微信界面所以一定要判断返回值别裸调。getWXAppSupportAPI()返回的是微信客户端支持的 SDK 版本号和Build.PAY_SUPPORTED_SDK_INT比较能过滤掉版本过老的微信。字段名注意packageValue和timeStamp的大写 S这两个是 SDK 里的属性名拼错了编译不过还算好事怕的是用反射或者 Gson 塞值的时候打错。4.3 Android 11 之后必须声明包可见性这是近几年最高频的新坑。Android 11 引入了包可见性限制App 默认查不到其他应用的安装信息。你的isWXAppInstalled()会永远返回false用户明明装了微信却提示未检测到微信客户端。解决办法是在AndroidManifest.xml里加一段queriesmanifest packagecom.your.pkg queries package android:namecom.tencent.mm / /queries application ... /application /manifest注意queries是manifest的直接子节点和application平级不能塞到application里面。这段加完之后isWXAppInstalled()和sendReq的跨应用跳转就都正常了。提示如果你用了一些老版本的第三方工具库做设备指纹采集它们可能也会因为包可见性返回空列表别把这两件事混在一起排查。5. 回调落地的关键WXPayEntryActivity 必须严丝合缝5.1 目录、类名、Manifest 三处必须完全一致微信客户端回调商户 App 的方式是用 Intent 启动一个约定好的 Activity。这个 Activity 的位置和名字是微信定死的你没有任何商量余地类名必须是WXPayEntryActivity必须放在以你 App 包名加.wxapi结尾的包下假设你的applicationId是com.example.shop那么这个文件必须放在com/example/shop/wxapi/WXPayEntryActivity.java。这里最容易翻车的是applicationId和namespace不一致的情况。Gradle 里如果配了applicationIdSuffix做多渠道或多环境实际安装到手机上的包名是带后缀的这时候.wxapi的目录也要跟着改否则微信按com.example.shop.debug.wxapi.WXPayEntryActivity去找找不到就静默失败你连报错都看不到。Manifest 注册也不能少activity android:name.wxapi.WXPayEntryActivity android:exportedtrue android:launchModesingleTop android:themeandroid:style/Theme.Translucent.NoTitleBar /exportedtrue是必须的因为要允许微信这个外部应用启动它。Android 12 之后不显式声明exported会直接编译报错反而成了一件好事。5.2 onResp 里到底该写什么public class WXPayEntryActivity extends Activity implements IWXAPIEventHandler { private IWXAPI api; Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); api WXAPIFactory.createWXAPI(this, App.APP_ID); api.handleIntent(getIntent(), this); } Override protected void onNewIntent(Intent intent) { super.onNewIntent(intent); setIntent(intent); api.handleIntent(intent, this); } Override public void onResp(BaseResp resp) { if (resp.getType() ConstantsAPI.COMMAND_PAY_BY_WX) { int code resp.errCode; String outTradeNo currentOutTradeNo(); if (code 0) { // 前端提示支付成功同时触发服务端查单 PayResultBus.post(new PayResult(outTradeNo, true, 支付成功)); } else if (code -2) { PayResultBus.post(new PayResult(outTradeNo, false, 已取消支付)); } else { PayResultBus.post(new PayResult(outTradeNo, false, 支付失败 resp.errStr)); } } finish(); } }几个实操要点。不要把结果直接写成 Toast 再finish()有些机型上 Toast 会跟着 Activity 一起被销毁用户什么都看不到。稳妥做法是把结果通过 EventBus、LiveData 或者广播发给主页由主页来弹提示然后这里立刻finish()。不要在onResp里发网络请求微信客户端回调后会很快回到前台你在这里阻塞用户体验会很差。onNewIntent一定要重写。当这个 Activity 因为singleTop已经存在时微信发来的新 Intent 会走onNewIntent而不是onCreate不处理就丢回调。5.3 为什么不能拿客户端回调当发货依据我在开头说过这条铁律这里展开讲讲原因。微信官方文档写得很清楚前端回调只代表微信客户端这一侧的操作结果它可能因为网络、进程被杀、用户手动清理后台等原因丢失。真实案例用户在微信收银台完成支付点完确认后微信闪退你的 App 永远收不到errCode 0。这时候如果发货逻辑挂在客户端回调上用户的钱就白花了。正确的做法是双保险。主链路是服务端接收微信的异步通知notify_url校验签名后把订单置为已支付并触发发货兜底链路是客户端收到errCode 0后主动调用自己的服务端查单接口服务端去微信查单确认后再返回最终状态。此外还应该有定时补单任务每 30 秒扫一次支付中状态的订单超过一定时间主动去微信查一次。服务端处理异步通知时还有两个细节必须做幂等微信可能重复推送同一笔通知用out_trade_no加唯一索引或者分布式锁都能解决必须返回正确的 XML 响应内容是xmlreturn_code![CDATA[SUCCESS]]/return_codereturn_msg![CDATA[OK]]/return_msg/xml格式不对微信会按失败处理并持续重推最多重推若干次后停止。6. 真机联调时最常撞上的几类故障6.1 errCode 对照与定位顺序拿到 errCode 之后别急着改代码先按固定顺序排查一遍效率能提高好几倍。errCode含义优先排查方向0支付成功提示 UI 是否正常、服务端是否已收到通知并发货-1错误签名错误、AppID 未与商户号关联、应用签名不匹配、订单已支付或已关闭、参数缺失-2用户取消无需排查正常业务分支-3发送失败sendReq时已返回 false检查 PayReq 字段是否齐全-4认证被拒授权目录、授权关系配置问题-5未安装微信包可见性配置、微信是否真的安装-1是占比最高的。我一般的排查顺序是先打印服务端返回的完整参数 JSON 和客户端PayReq各字段做逐字对比确认没有被前端二次加工再确认开放平台后台的应用签名是否等于当前包的实际签名再确认商户平台上 AppID 的关联状态最后才怀疑签名算法或者 API 密钥。有一次我遇到一个很奇葩的-1查了三个小时最后发现是运维在网关层对请求参数做了 URL 编码处理把package字段里的等号编成了%3D服务端签的是原文客户端拿到的是编码后的签名自然对不上。中间有任何一层网关、反代、Mock 工具都要检查它有没有动过参数。6.2 混淆、加固与热修复带来的隐性故障微信 SDK 的类如果被混淆WXPayEntryActivity里的handleIntent和onResp就不会被正确触发表现是微信里明明付完了回到 App 什么反应都没有。所以混淆规则必须加上-keep class com.tencent.mm.opensdk.** { *; } -keep class com.tencent.wxop.** { *; } -keep class com.tencent.mm.sdk.** { *; } -keep class com.example.shop.wxapi.** { *; }最后一行是很多人会漏的你自己的wxapi包也要 keep因为微信是通过反射和固定类名来找它的。如果你的项目用了加固平台要注意加固后 APK 的签名可能被改写某些加固方案会在签名校验环节动手脚。这种情况下必须用加固后的包实际测一遍支付。同理热修复框架如果更新了wxapi包下的类也可能导致类名映射异常建议把wxapi包加入热修复的黑名单。6.3 订单重复、超时与补单策略上线之后你会遇到三类订单异常用户短时间内连点两次支付按钮产生两笔out_trade_no不同的订单用户只付了一笔另一笔挂着。解决办法是在客户端点击后立刻置灰按钮服务端对同一业务订单做去重复用未支付的prepayId两小时有效。prepayId过期用户调起支付界面后放置超过两小时才确认。这时候微信会报错正确做法是服务端捕获错误后重新下单返回新的prepayId让客户端重试一次。异步通知没到或者处理失败订单长期停留在支付中。这就是补单任务存在的意义。我的经验是补单间隔不要设得太密30 秒到 1 分钟比较合适微信查单接口本身有频率限制扫太勤容易被限流。订单状态机建议至少包含这四个状态待支付、支付中、已支付、已关闭、已退款。每次状态流转都记录一次流水出问题的时候翻流水比翻日志快得多。7. 上线前的自查清单每次发版前我都会把下面这张表过一遍尤其是换过签名文件或者改过applicationId的版本。检查项判断标准开放平台应用签名等于正式包实际签名的 MD5无冒号小写包名一致性开放平台、商户平台、Manifest、wxapi目录四处完全一致AppID 与商户号关联商户平台显示已关联queries声明已包含com.tencent.mmexported属性WXPayEntryActivity已设为 true混淆规则三条 keep 规则齐全含自己的wxapi包发货依据只依赖服务端异步通知和主动查单不依赖客户端回调幂等处理异步通知和查单接口都有幂等保护补单任务定时扫描支付中订单有告警小额真机验证用真实 1 分钱订单跑通全链路最后分享一个我在实践中觉得挺管用的小技巧在测试包里加一个隐藏的支付诊断入口点进去自动打印当前包的签名 MD5、applicationId、SDK 版本、微信是否安装、微信支持的 API 版本再顺手把最近一次下单返回的完整参数 JSON 打出来。真机出问题的时候让测试同事截一张这个页面发过来比在群里来回问半天高效得多。这套东西搭完之后微信支付接入其实就没什么神秘的了剩下的是把异常分支补全、把日志打够、把补单做扎实。真正难的不是那几十行 Java 代码而是把整条链路上每个环节的责任划清楚然后老老实实地做双保险。