
做小程序支付接入这件事我自己第一次上手时光是在“JSAPI支付必须传openid”这个问题上就卡了快一整天。当时项目用的是Java后端微信支付又刚好升级到了V3版本网上能搜到的资料七成是V2的老代码三成是云厂商的付费方案真正能把V3版JSAPI支付从小程序端到服务端完整跑通的实战源码少之又少。这篇文章不聊空概念直接把我整理并验证过的JSAPI微信支付小程序V3版本Java实战方案拆开讲。从最基础的场景认知、账号准备到统一下单、二次签名、回调验签、查单关单、退款再到我实际踩过的坑一条链路完整走下来。如果你正被“openid怎么拿”“证书怎么配”“回调验签老失败”这类问题折磨这篇就是给你准备的。1. 先把JSAPI支付V3这件事吃透1.1 为什么小程序支付一定要传openid这是很多新手第一个崩溃的点。明明在微信支付里选了“JSAPI支付”文档也提示“openid必传”但就是搞不懂这个openid从哪里来、怎么和用户关联上。JSAPI支付的“JS”指的不是JavaScript而是微信生态内的公众号、小程序网页环境。它的特点是支付动作发生在微信客户端内用户看到的是微信原生的支付弹层。正因为人在微信里微信才可以用openid来唯一标识这个用户在某一个小程序下的身份。openid不是用户ID也不是昵称。同一个用户在小程序A和小程序B里openid是不同的小程序同一小程序下同一个用户openid是固定的。它是“某个用户 某个小程序”这个组合的唯一键。JSAPI支付必须传openid是因为微信需要确认“这笔订单是哪个微信用户发起的”后续的扣款、退款、账单核对、风控都建立在这个身份基础上。实际项目中正确的获取方式是小程序端先调用wx.login拿到临时凭证code传到后端后端拿着这个code调用微信的jscode2session接口换取openid和session_key。注意code一次性有效、五分钟过期所以必须做到“拿到就换、换完即用”。我之前见过有人在前端把code存在本地隔天再拿去换openid结果自然是失败。1.2 V3版本到底比V2强在哪里微信支付V3是官方目前主推的API版本和V2最大的区别可以浓缩成四点第一接口风格从XML变成JSON。V2时代POST一个XML报文响应也是XML解析起来非常痛苦V3全部是JSON序列化和反序列化省事太多和Java生态的契合度也高。第二签名机制完全重构。V2用MD5或HMAC-SHA256做签名密钥一旦泄露攻击者可以任意伪造请求。V3改用微信支付平台证书 RSA非对称签名请求用商户私钥签名响应和回调用微信平台公钥验签安全性高了一个量级。第三回调数据加密传输。V3的回调报文不是明文body里的resource字段是AES-256-GCM加密后的密文需要用APIv3密钥解密才能拿到真正的支付结果。这一点经常被忽略很多人验签通过了却在解密时翻车。第四证书体系更清晰。V3同时存在“商户API证书”用于证明商户身份和“微信支付平台证书”用于验签和加密两者用途完全不同不能混用。很多报错“证书序列号不匹配”就是这里搞混了。除了这四个差别V3还把所有接口统一收敛到api.mch.weixin.qq.com/v3/路径下统一了报错格式code message排查问题比V2舒服得多。1.3 一次完整支付要经过哪些环节画一下链路你会更清楚“代码该写在哪里”小程序前端发起支付 - 用户同意授权登录wx.login- 后端用code换openid - 后端调微信统一下单接口 - 拿到prepay_id - 后端用prepay_id生成调起支付所需的参数二次签名- 返回给小程序 - 小程序调wx.requestPayment - 用户输密码/指纹支付 - 微信异步回调后端通知支付结果 - 后端验签、解密、更新订单状态 - 小程序通过wx.requestPayment的success回调刷新页面。这个链路里后端要做的事情至少五件换openid、统一下单、二次签名、处理回调、查单/关单兜底。我的代码就是按这五件事拆分服务的。2. 动手前必须准备好的账号与密钥2.1 商户号和AppID的绑定关系要跑通真实支付你首先要有自己的服务商商户号或者在微信支付商户平台上申请的普通商户号。在商户平台里找到“产品中心 - AppID账号管理”把小程序AppID关联进来。AppID和小程序的对应关系是一对一一个商户号可以绑定多个AppID一个AppID也可以被多个商户号绑定服务商模式但普通商户模式下一个AppID只能绑定一个商户号。如果你的项目还在开发阶段没有真实商户号可以用沙箱环境测试但沙箱不包含JSAPI支付全流程更多是用来验证签名。真正要联调JSAPI建议直接申请真实商户号小额测试比如0.01元即可。2.2 APIv3密钥、商户API证书、微信支付平台证书这三个是V3的灵魂很多人一开始分不清贴个对照表名称作用谁持有典型使用场景APIv3密钥AES-256-GCM密钥用于回调数据解密商户自行设置微信也保存一份解密支付结果回调商户API证书证明商户身份含商户私钥和公钥证书商户申请并妥善保管私钥请求签名、请求微信接口微信支付平台证书微信官方的公钥证书用于验签微信提供商户下载保存验签微信回调、验签微信响应实践中商户API证书一般在商户平台“账户中心 - API安全 - 申请API证书”里生成申请过程会让你填写证书序列号并用私钥生成CSR文件然后把CSR上传换取证书。这个证书有效期通常是5年到期前需要重新申请否则所有请求会直接报“CERTIFICATE NOT EXIST”。微信支付平台证书文件有两个获取渠道一是从商户平台直接下载二是通过/v3/certificates接口动态拉取。动态拉取需要先完成请求签名鸡生蛋蛋生鸡所以第一次还是直接从平台下载比较省事。2.3 商户私钥如何从pem文件加载到Java微信支付平台下载的商户API证书是一个.p12或.jks或.pem格式的文件。推荐直接把私钥导出为PKCS8格式的pem文件用Java原生代码加载不依赖微信SDK。导出私钥的参考命令拿到的是apiclient_key.pem这个文件千万不能泄露openssl pkcs12 -in apiclient_cert.p12 -nocerts -nodes -out apiclient_key.pem openssl pkcs8 -topk8 -inform PEM -in apiclient_key.pem -outform PEM -nocrypt -out apiclient_key_pkcs8.pem然后在Spring项目中把私钥内容配置到application.yml启动时读取到内存别每次请求都重新解析文件性能差且容易有IO异常。2.4 Maven依赖如何选型关于要不要引入微信官方SDK我的建议是生产项目优先用官方wechatpay-java因为它封装了平台证书下载、自动更新、请求签名、响应验签省掉大量重复代码。但如果你需要定制化程度高比如自己管理证书、自定义HTTP客户端那用原生HttpClient 自己实现签名也完全可以。官方SDK坐标如下dependency groupIdcom.github.wechatpay-apiv3/groupId artifactIdwechatpay-java/artifactId version0.2.12/version /dependency不过我的实战源码里核心逻辑并没有完全依赖SDK而是用“SDK封装支付底层 业务层自研”的方式组织。这样做的原因是SDK更新快但你的业务结构不能跟着它变底层签名、HTTP调用可以交给SDK但订单状态机、回调处理、幂等校验这些必须自己掌控。3. Java代码实战统一下单与二次签名3.1 小程序登录换openid的原生实现先说code2Session这个接口。微信官方接口地址是https://api.weixin.qq.com/sns/jscode2session?appidAPPIDsecretSECRETjs_codeCODEgrant_typeauthorization_codeJava后端用RestTemplate或HttpClient发起GET请求代码示意如下public String getOpenid(String code) { String url https://api.weixin.qq.com/sns/jscode2session?appid appid secret secret js_code code grant_typeauthorization_code; String response restTemplate.getForObject(url, String.class); JSONObject json JSONObject.parseObject(response); if (json.getInteger(errcode) ! null json.getInteger(errcode) ! 0) { throw new BizException(换取openid失败 json.getString(errmsg)); } return json.getString(openid); }拿到openid后强烈建议把它和用户主键会员ID、用户表ID绑定写入user_openid表后续下单直接查表复用不必每次都调code2Session。session_key则不要存数据库它用于解密手机号等敏感信息存了反而增加泄露风险。3.2 构建统一下单请求微信支付V3统一下单接口是POST https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi请求体关键字段如下{ appid: 小程序AppID, mchid: 商户号, description: 商品描述, out_trade_no: 商户订单号, notify_url: https://example.com/api/pay/notify, amount: { total: 1, currency: CNY }, payer: { openid: 用户openid } }需要注意几点都是我在生产环境踩过的amount.total单位是分整数类型不是元。1元就是1000.01元就是1。如果你数据库存的是BigDecimal的元乘以100后要转int同时注意四舍五入问题。用total.multiply(BigDecimal.valueOf(100)).setScale(0, RoundingMode.HALF_UP).intValue()。out_trade_no是你自己生成的唯一订单号格式建议统一比如“商户标识 年月日时分秒 随机数”长度限制32字符。别用自增主键因为未来可能多端共用也别用UUID太长且不可读。我常用的生成方式是String outTradeNo PAY System.currentTimeMillis() RandomUtil.randomNumbers(6);notify_url必须是公网可访问的HTTPS地址且不能带参数、不能带端口生产环境。微信回调如果连不上这个地址会按策略重试首次失败后隔15秒、30秒、60秒……重试最长持续24小时。3.3 Java原生实现RSA签名并提交下单如果你不引入官方SDK自己实现签名也不复杂。V3的签名串格式是HTTP请求方法\n URL路径\n 请求时间戳\n 请求随机串\n 请求报文摘要\n其中“请求报文摘要”是请求body的SHA256后再Base64编码。然后用自己的商户私钥对签名串做SHA256withRSA签名得到签名值。最后把签名值、商户号、证书序列号、随机串、时间戳放进HTTP头。下面是我封装的一个核心签名方法public String sign(String method, String urlPath, long timestamp, String nonceStr, String body) { String message buildMessage(method, urlPath, timestamp, nonceStr, body); try { Signature sign Signature.getInstance(SHA256withRSA); sign.initSign(privateKey); sign.update(message.getBytes(StandardCharsets.UTF_8)); return Base64.getEncoder().encodeToString(sign.sign()); } catch (Exception e) { throw new RuntimeException(签名失败, e); } } private String buildMessage(String method, String urlPath, long timestamp, String nonceStr, String body) { return method \n urlPath \n timestamp \n nonceStr \n body \n; }注意签名串的换行符是\n最后一行body后面也有换行。很多人在这里少写换行导致签名的摘要和服务端计算不一致请求直接报“签名错误”。提交下单请求时别用JSONObject乱拼直接用Map Jackson序列化保证字段顺序稳定。发起请求后微信返回的预支付信息长这样{ prepay_id: wx3111111111111111111 }这个prepay_id是后续调起支付的凭证有效期默认2小时。3.4 二次签名小程序调起支付的前置条件拿到prepay_id只完成了一半。小程序端wx.requestPayment需要你自己生成一个支付参数对象这个对象的签名可不是统一下单的签名而是另一套逻辑。需要的字段是appId小程序AppID、timeStamp秒级时间戳、nonceStr随机串、package固定格式prepay_idxxx、signTypeRSA、paySign。算paySign的签名串是appId\n timeStamp\n nonceStr\n package\n注意这里没有方法、没有URL没有body只有四行。还是用商户私钥做SHA256withRSA签名Base64编码后得到paySign。代码如下public String buildPaySign(String appId, String timeStamp, String nonceStr, String prepayId) { String message appId \n timeStamp \n nonceStr \n prepay_id prepayId \n; // 用同一把商户私钥做RSA签名返回Base64 }完整的返回给前端的JSON结构是{ timeStamp: 1710000000, nonceStr: 5K8264ILTKCH16CQ2502SI8ZNMTM67VS, package: prepay_idwx3111111111111111111, signType: RSA, paySign: XXXXXX }前端拿到这个对象后直接调wx.requestPayment({ timeStamp: res.data.timeStamp, nonceStr: res.data.nonceStr, package: res.data.package, signType: res.data.signType, paySign: res.data.paySign, success: () { /* 支付成功但不要在这里更新订单等后端回调 */ }, fail: (err) { /* 用户取消或失败 */ } })注意wx.requestPayment的success回调只代表微信客户端成功唤起支付流程不代表钱已经到你账户。最终订单状态必须以后端收到的回调为准。这是我反复强调的一点很多初级程序员在前端success里直接改订单状态一旦微信回调延迟或丢失账单和前端就会不一致。4. 回调处理验签、解密、幂等一个都不能少4.1 为什么回调验签容易失败微信支付成功后微信服务器会向你的notify_url发送一个POST请求请求头里有Wechatpay-Signature、Wechatpay-Timestamp、Wechatpay-Nonce、Wechatpay-Serial四个字段。body是JSON核心结构如下{ id: EV-XXXXXXXXXXXXXXXX, create_time: 2024-03-01T12:00:0008:00, resource_type: encrypt-resource, event_type: TRANSACTION.SUCCESS, summary: 支付成功, resource: { original_type: transactions, algorithm: AEAD_AES_256_GCM, ciphertext: BASE64密文, associated_data: 附加数据, nonce: 随机串 } }验签失败最常见的原因有三个第一没有用微信支付平台证书。你本地可能存的是商户API证书的公钥用它验微信的签名当然会失败。一定要用微信支付平台证书的公钥验签。第二验签签名串拼错了。回调的验签签名串是Wechatpay-Timestamp值\n Wechatpay-Nonce值\n body原始JSON文本\n第三行是body原文不是解析后的某些字段必须用原始请求体千万别用Gson或Jackson再序列化一遍字段顺序变了验签必挂。我一般直接用RequestBody String requestBody接收原始JSON字符串再解析成对象。官方SDK里对验签封装很好如果你自己写可以这样验public boolean verifyNotify(String body, String timestamp, String nonce, String signature) { String message timestamp \n nonce \n body \n; try { Signature sign Signature.getInstance(SHA256withRSA); sign.initVerify(wechatPayPlatformPublicKey); sign.update(message.getBytes(StandardCharsets.UTF_8)); return sign.verify(Base64.getDecoder().decode(signature)); } catch (Exception e) { return false; } }4.2 回调数据解密到底怎么用APIv3密钥验签通过后还不能直接拿数据用因为resource.ciphertext是加密的。解密需要三个要素APIv3密钥、nonce、associated_data通常叫aad。AES-256-GCM的解密流程不复杂但Java标准库的语法有点绕。直接放一个完整的解密工具方法public static String decrypt(byte[] apiV3Key, byte[] nonce, byte[] ciphertext, byte[] associatedData) throws Exception { Cipher cipher Cipher.getInstance(AES/GCM/NoPadding); SecretKeySpec keySpec new SecretKeySpec(apiV3Key, AES); GCMParameterSpec gcmSpec new GCMParameterSpec(128, nonce); cipher.init(Cipher.DECRYPT_MODE, keySpec, gcmSpec); if (associatedData ! null) { cipher.updateAAD(associatedData); } byte[] plaintext cipher.doFinal(ciphertext); return new String(plaintext, StandardCharsets.UTF_8); }这里最容易翻车的点是GCMParameterSpec的第一个参数它是GCM的tag长度微信固定用128位。设置成96或120都会解密失败报AEADBadTagException。解密后的明文长这样{ appid: 小程序AppID, mchid: 商户号, out_trade_no: 商户订单号, transaction_id: 微信支付订单号, trade_type: JSAPI, trade_state: SUCCESS, trade_state_desc: 支付成功, success_time: 2024-03-01T12:00:0008:00, payer: { openid: 用户openid }, amount: { total: 1, payer_total: 1, currency: CNY, payer_currency: CNY } }先从密文解析出out_trade_no和amount.total然后去数据库查这笔本地订单核对金额一致、状态是“待支付”才能更新为“已支付”。金额不一致立刻告警疑似参数被篡改。4.3 回调处理必须做幂等微信回调机制是“最多成功一次、可能重复多次”。如果回调处理逻辑不具备幂等性用户支付成功但服务端因网络或业务异常没返回200微信会反复重试同一个回调你的订单状态可能被重复更新甚至引发超发、重复发货等严重问题。幂等的做法是以out_trade_no为唯一维度。在更新订单状态时先用select for update或乐观锁校验当前状态只有“待支付”才允许改成“已支付”。已经处理过的回调直接返回200 成功响应别再重复执行业务逻辑。我还会在回调处理前先查一次外部订单状态用微信的/v3/pay/transactions/out-trade-no/{out_trade_no}接口确认这笔订单在微信侧确实是SUCCESS双重保险。4.4 给微信的正确响应格式处理完回调需要返回HTTP 200并且body是{code: SUCCESS, message: 成功}。如果返回非200微信会重新推送。很多人以为返回200就完事了结果body格式不对微信照样当成失败。新手最容易犯的错是回调处理失败时返回200但body是错误信息微信以为处理成功便不再重试订单永远卡在“已支付但未处理”的状态。正确的做法是PostMapping(/api/pay/notify) public ResponseEntityString notify(RequestBody String requestBody, RequestHeader(Wechatpay-Signature) String signature, RequestHeader(Wechatpay-Timestamp) String timestamp, RequestHeader(Wechatpay-Nonce) String nonce) { try { boolean ok verifyNotify(requestBody, timestamp, nonce, signature); if (!ok) { return ResponseEntity.status(401).body({\code\:\FAIL\,\message\:\验签失败\}); } PayNotifyDto dto decryptNotify(requestBody); handleOrderPaid(dto); return ResponseEntity.ok({\code\:\SUCCESS\,\message\:\成功\}); } catch (Exception e) { log.error(回调处理失败, e); return ResponseEntity.status(500).body({\code\:\FAIL\,\message\:\处理失败\}); } }5. 签字之外的兜底查单、关单、退款与投诉回调5.1 主动查单什么时候用回调是异步的而且理论上可能丢失。虽然微信有重试机制但在极端情况下比如你的服务停机超过24小时回调无法到达。所以必须提供主动查单能力。主动查单一般出现在两个场景一是用户在前端支付成功后小程序轮询后端“订单是否变更为已支付”后端查本地订单如果还是“待支付”就主动调微信查单接口确认真实状态二是定时任务兜底比如每5分钟扫描超过10分钟未支付的订单调微信查单。查单接口GET https://api.mch.weixin.qq.com/v3/pay/transactions/out-trade-no/{out_trade_no}?mchidMCHID注意GET请求签名串里的URL路径不包含query参数。?mchidxxx是query不参与签名串拼接。调用后如果返回trade_state为SUCCESS要立刻走一次和回调相同的订单更新逻辑如果是NOTPAY继续等待如果是CLOSED说明订单已关闭。5.2 关单接口和撤销的区别超时未支付的订单需要调用关单接口POST https://api.mch.weixin.qq.com/v3/pay/transactions/out-trade-no/{out_trade_no}/close请求体只需要mchid。关单之后这个out_trade_no就不能再使用了必须重新生成新订单号。注意关单和撤销是两个概念关单适用于未支付的订单撤销适用于已支付但未结算或支付结果不明确的订单撤销是支付服务特有的能力JSAPI普通商户也有但不要混用。业务上我建议设一个“订单支付超时时间”比如30分钟。前端倒计时结束后端关单并释放库存。如果用户在超时前1秒发起支付后端在回调处理时要判断关单状态已关单的回调直接返回成功但标记异常避免用户付了钱订单却被关了。5.3 退款接口与回调退款接口是POST https://api.mch.weixin.qq.com/v3/refund/domestic/refunds请求体核心字段{ out_trade_no: 原商户订单号, out_refund_no: 退款单号, amount: { refund: 100, total: 100, currency: CNY } }退款同样有回调接口路径是/v3/refund/domestic/refunds/notify回调结构类似支付回调也是加密的resource字段解密后能看到refund_status。退款回调的验签和解密流程和支付回调完全一致只是业务处理逻辑不同。退款这里有个经典大坑amount.total是原订单总金额refund是退款金额两者都要传而且单位都是分。如果原订单是100分你想退50分那总金额还是传100退款金额传50。很多新手认为total是“本次退款后的剩余金额”结果全退错。退款一般实时到账但也有银行处理延迟的情况所以要靠回调确认最终退款状态别在前端点击“退款成功”就完事。5.4 投诉回调也要接入微信支付V3有“消费者投诉”接口当用户发起投诉时微信会回调你的投诉通知地址。接口路径POST /v3/merchant-service/complaint-notify这个回调在商户平台的“API安全 - 回调配置”里单独配置。写法和支付回调类似但解密后的数据会包含complaint_id、out_trade_no、complainted_mchid等。建议开发一个独立的Controller接收投诉回调并及时响应避免微信侧判定“商户不响应投诉”影响店铺评分。我知道很多中小团队会忽略投诉回调但它在真实运营中非常重要。及时接入后哪怕只是自动回复都能降低微信的风控负面影响。6. 常见问题与排查技巧实录6.1 热词“jsapi支付必须传openid怎么解决”的完整梳理这个问题本质是“openid怎么在服务端正确获取”。完整链路如下小程序端wx.login取code - 传给后端 - 后端调code2Session拿openid - 把openid存到用户表 - 下单时从用户表读取openid放进payer.openid。常见错误有两种一是前端没有正确把code传给后端建议打印日志确认code非空且一次性使用二是code2Session没有使用小程序的AppID和Secret而是用了公众号的这是配置错了维度。另外小程序如果换了AppIDopenid体系会完全改变用户在新AppID下会被当成新用户老订单的openid对不上这是业务层面要提前考虑清的。6.2 签名报错“请验证签名是否正确”的排查表这个报错是V3接入中出现频率最高的没有之一。我总结了一套从日志入手排查的顺序检查商户号是否配对签名用的商户私钥必须归属于请求头里的mchid。检查证书序列号是否正确请求头里Wechatpay-Serial是商户API证书序列号不是微信支付平台证书序列号。商户API证书序列号在证书详情页能看到标准格式是40位大写十六进制。检查签名串换行符确保最后一行后有\n。检查URL路径是否去掉域名和query签名串里URL路径是/v3/pay/transactions/jsapi不是https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi。确认body用的是原始请求体不是JSONObject重新序列化的。确认时间戳误差在5分钟内微信会拒绝时间偏差过大的请求。如果以上都检查了还是失败最有效的办法是用微信支付官方提供的签名校验工具商户平台-API安全-接口调试把你的签名串和计算结果贴进去比对能很快定位是“串拼错”还是“算法错”。6.3 微信支付金额单位换算与精度问题这是业务常见bug重灾区。我的建议是数据库金额一律用BigDecimal以“元”为单位存储对外传输时转成分并且提供一个专门类型来处理避免每个业务散落着*100和/100的代码。订单金额计算一律用BigDecimal不用double。为什么double的浮点精度会导致0.1元无法精确表示多次累加后误差累积万一给别人多扣一分钱或者退款多退一分钱对账时极其麻烦。我的方案public class MoneyUtil { public static int toFen(BigDecimal yuan) { return yuan.multiply(BigDecimal.valueOf(100)) .setScale(0, RoundingMode.HALF_UP) .intValue(); } public static BigDecimal toYuan(int fen) { return BigDecimal.valueOf(fen) .divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP); } }6.4 微信支付平台证书过期或轮换微信支付平台证书不是永久的会定期轮换。如果你想永久不维护必须调用/v3/certificates接口拉取最新证书。官方SDK的AutoCertificateService就是干这个的它会自动检查平台证书是否过期过期则自动下载更新。我遇到过一次线上半夜突然回调验签失败的故障排查了半小时才意识到是微信侧平台证书轮换了而本地存的还是旧证书。从那之后凡是生产环境我都强制接入自动更新逻辑绝不手动管理平台证书。6.5 回调处理太慢导致微信重试风暴回调处理里如果同步做了很多事情——查库存、扣库存、发短信、push、调外部ERP——就会拉长响应时间严重时超过5秒微信超时重试而你这边可能刚处理成功重复回调再次打进来产生并发问题。我的建议是回调接口只做“验签 - 解密 - 更新订单状态 - 返回成功”这四件事后续的库存扣减、物流推送、积分发放全部扔进消息队列异步处理。这样回调接口的RT能控制在100ms以内重试风暴根本不会发生。6.6 openid乱串一台服务多个小程序如果同一套后端同时服务多个小程序比如多租户系统每个订单必须携带自己的appid。统一下单的时候appid、mchid、payer.openid三者必须来自同一个租户配置不能混用。实际中我见过一个问题两个小程序共用一个商户号用户在A小程序支付成功后B小程序的订单回调居然也收到了这笔支付通知。原因是notify_url配的是同一个回调里要根据resource.appid判断该更新哪个租户的订单不能只靠商户订单号差订单。6.7 常见问题速查表现象可能原因解决办法请求报“签名错误”签名串拼接错误或商户私钥不匹配用官方签名校验工具对比请求报“证书序列号不存在”用的是微信支付平台证书序列号而非商户证书序列号换成商户API证书序列号回调验签失败用了错误的平台公钥或签名串拼错确保证书是微信支付平台证书校验原始body解密失败 AEADBadTagExceptionAPIv3密钥错误或GCM tag长度不是128检查APIv3密钥并在conf平台重新设置tag长度固定128订单已支付但回调没收到notify_url不可达或处理失败检查回调日志、用查单接口兜底金额不对元分换算错误统一用MoneyUtil处理openid无效code过期或AppID不匹配code只使用一次5分钟内换取主动查单失败URL路径拼接错误路径不含query参数mchid放query7. 一段实际可跑的核心代码支付服务如果说上面是原理和方案的讲解那这里直接给你一段可以抄到项目里的核心代码骨架。这样你对照着跑通再结合自己的业务去扩展。7.1 引入依赖和配置!-- 官方SDK为了平台证书自动管理实际上签名仍可用自研方式 -- dependency groupIdcom.github.wechatpay-apiv3/groupId artifactIdwechatpay-java/artifactId version0.2.12/version /dependency7.2 后端统一支付服务Service public class WechatPayService { Autowired private WechatPayConfig config; /** * 1. 小程序登录返回 openId */ public String getOpenId(String code) { // 见前面代码 } /** * 2. JSAPI 统一下单返回前端调起支付参数 */ public MapString, String createJsapiOrder(String openId, String outTradeNo, int totalFee, String description) { Config wxConfig config.getWxConfig(); // 用SDK构造请求 RequestParam param new RequestParam.Builder() .setMethod(HttpMethodEnum.POST) .setUrl(/v3/pay/transactions/jsapi) .setBody(JSON.toJSONString(buildOrderBody(openId, outTradeNo, totalFee, description))) .build(); // 发送请求并获取prepayId HttpResponse response createService().getRSAAutoCertificateConfig() .createSigner() .sign(param); // 实际SDK写法略有区别这里示意源码已整理为可直接运行的完整版本 String rawBody response.getBody(); JSONObject json JSONObject.parseObject(rawBody); String prepayId json.getString(prepay_id); // 二次签名 String timeStamp String.valueOf(System.currentTimeMillis() / 1000); String nonceStr RandomUtil.randomString(32); String paySign signForPay(wxConfig.getAppId(), timeStamp, nonceStr, prepayId); MapString, String result new HashMap(); result.put(timeStamp, timeStamp); result.put(nonceStr, nonceStr); result.put(package, prepay_id prepayId); result.put(signType, RSA); result.put(paySign, paySign); return result; } /** * 3. 处理支付回调 */ public void handleNotify(String requestBody, String timestamp, String nonce, String signature) { verify(requestBody, timestamp, nonce, signature); String plaintext decryptResource(requestBody); // 更新订单状态必须幂等 // ... } }这里需要说明的是官方SDK的构造和使用细节在不同版本有差异如果你选用官方SDK一定以com.github.wechatpay-apiv3的官方文档为准。如果想看稳定可控的实现我建议的完整源码版本是不依赖SDK的用HttpClient直接请求 自己实现RSA签名和AES解密的完整工程。这样你完全掌控所有逻辑也方便扩展多个商户。7.3 前端小程序调用代码参考// 小程序端拿openid - 调后端下单 wx.login({ success: (loginRes) { wx.request({ url: https://api.you.com/api/pay/jsapi, method: POST, data: { code: loginRes.code, orderId: xxx }, success: (res) { const payParams res.data; wx.requestPayment({ timeStamp: payParams.timeStamp, nonceStr: payParams.nonceStr, package: payParams.package, signType: payParams.signType, paySign: payParams.paySign, success: () { // 这里不要改订单状态去轮询后端 pollOrderStatus(xxx); }, fail: (err) { console.log(支付取消, err); } }); } }); } }); function pollOrderStatus(orderId) { let times 0; const timer setInterval(() { wx.request({ url: https://api.you.com/api/pay/status?orderId orderId, success: (res) { if (res.data.paid) { clearInterval(timer); wx.showToast({ title: 支付成功 }); } else if (times 12) { clearInterval(timer); } } }); }, 2000); }这个轮询方案简单可靠能在不依赖WebSocket的情况下覆盖绝大多数支付结果同步需求。8. 线上运营中必须盯紧的几个细节代码能跑通只是第一步。真正上线之后有几个点如果没做好你的支付系统会在某个神秘时刻突然出问题。第一日志必须完整。每次下单需要记录请求流水号、openid、订单号、金额、prepay_id、前端IP。回调需要记录原始body、验签结果、解密后的明文、处理耗时。日志格式要统一方便用日志平台检索。我见过太多“订单不见了”的问题最后全靠日志定位。第二对账是底线能力。微信支付商户平台可以下载对账单但那是T1的。建议你自己用定时任务每天凌晨拉一次前一天的微信支付对账单和本地订单表做比对。对不上的要自动告警。这不是可选项是支付系统的标配。第三密钥管理要严格。商户私钥文件、APIv3密钥绝对不要放在Git仓库里。用环境变量或者配置中心管密钥上线前的密钥要定期轮换。如果在排查问题时泄漏了密钥立刻在商户平台重置并重新生成证书。第四HTTPS必须到位。回调地址必须是HTTPS不能用自签证书。小程序域名白名单里的request合法域名也必须是HTTPS且ICP备案、没有非标端口。最后再分享一个小技巧如果你是被微信支付各种签名、解密细节折磨得够呛的开发者我建议你把签名、解密、HTTP请求、回调处理这些底层能力封装成一个独立的pay-core模块业务系统只依赖这个模块的接口。这样做的好处是以后不管接小程序、App、公众号还是Native支付底层通用能力不用重写只需要根据场景增加具体的下单逻辑。另外你在调试的时候可以开一个临时的、只面向内网的测试通知地址比如用内网穿透工具暴露本机端口把微信回调打到本地Debug这样能极大加快调通速度。我踩过无数次“回调收不到”的坑后才确定80%的问题都能通过在本地看完整请求日志直接看出来。JSAPI支付V3的坑确实不少但也正因为坑多把它彻底跑通之后你对微信支付整个体系的理解会一下子通透起来后面再接其他支付场景就会顺手很多。希望这篇文章能帮你省下我当时踩坑时浪费的那些时间。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。