简介:微信JSAPI支付完整示例Demo,覆盖关闭订单、查询订单、查询退款、下载对账单、申请退款等核心接口,支持商户平台常见售后场景,适合正在集成微信支付的开发者作为参考工程,帮助快速理清支付及售后环节的接口调用流程。压缩包共498个文件,包含38个jar依赖库、16个Java源码文件、7个properties配置、7个xml配置、5个jsp页面以及2个p12证书文件,整体约23.09MB,目录内附有工程配置文件,涵盖请求封装、配置加载、页面交互等模块,可直接导入IDE对照学习。已有946人学习下载,可见其参考价值。通过该Demo可以了解微信支付证书加载、参数签名、订单状态查询与退款处理的具体写法,减少踩坑,也能帮助理解微信支付API调用顺序及异常处理,适合有一定支付开发基础但需要完整示例的工程师,对二次开发和支付功能测试均有参考意义。 微信支付这块,我一直有个观点:把JSAPI支付Demo 跑起来不难,难的是把它跑成一套能覆盖完整交易闭环的代码。很多新手照着文档敲完了统一下单,就以为完事了,等真正上线要接关闭订单、查询订单、申请退款、查询退款、下载对账单这些接口时,才发现每个接口都有自己的脾气,要么签名报错,要么回调解密失败,要么对账单解析出来乱码。这篇文章就把我这几年接微信支付的实战经验整理成一套可复用的Demo思路,适合刚接触微信支付的后端同学,也适合那些已经跑通了支付但一直被售后接口折磨的团队。
1. 先看清这套Demo的交易闭环要解决什么问题
1.1 六个接口组合在一起的业务意义
很多人把微信支付理解成"用户点一下支付、钱到账、完事",但真实系统里,支付只是开始。用户超时没付款,你得有办法释放订单,这就是关闭订单;支付回调没收到,不能让用户干等,要去查询订单确认状态;用户要退款,后台得能发起申请退款,退款是异步过程,还得查询退款看进度;月底财务要对账,一个个拉流水不现实,得靠下载对账单和微信侧的交易流水做核对。这六个接口合起来,才是一个商城、预约系统、知识付费等场景真正需要的支付能力。
这个Demo里的模块划分我是按"支付主链路"和"交易售后链路"来组织的。支付主链路是统一下单、调起支付、回调解密,售后链路是关闭、查询、退款、查退款、对账单。写代码前一定先把这两条链路分开,否则所有逻辑堆在Controller里,后续加个对账定时任务都要小心翼翼。
1.2 跑Demo前必须准备好的密钥和资质
JSAPI支付的前置条件和Native、App支付不一样,必须满足三点:一是微信公众号必须是服务号且完成微信认证,个人订阅号是不行的;二是要有商户号,并且和公众号完成绑定;三是服务器必须配置HTTPS域名,微信回调要求公网可访问的HTTPS地址。
API方面需要准备的核心密钥是三件套:商户API证书(用于请求签名)、API v3密钥(用于回调数据解密)、商户号。其中商户API证书是pem格式的apiclient_cert.pem和apiclient_key.pem,这两个文件一定不能提交到代码仓库,Demo里写死路径没问题,真实项目请放到环境变量或配置中心。
还有一个高频踩坑点:JSAPI支付必须拿到用户的openid,而且这个openid是当前公众号下的用户唯一标识,不是全局的unionid。如果直接拿别的平台或App的openid来下单,微信会直接报USER_OPENID_ERROR。获取openid需要走网页授权,静默授权即可,用户甚至无感知。
2. API v3签名与统一请求封装,决定Demo质量的隐藏分水岭
2.1 签名字符串到底怎么拼
微信支付API v3的签名机制,是很多Demo写了一个又一个Controller却依然报SIGN_ERROR的根本原因。它要求对请求做两部分签名:请求头里的Authorization和回调通知的验签。先说请求头签名,规则是拼接字符串:
HTTP方法\n URL路径(包含Query参数)\n 请求时间戳\n 请求随机串\n 请求体摘要\n \n注意这里的\n是真实换行,URL路径部分比较坑,比如查询订单的URL是/v3/pay/transactions/out-trade-no/{out_trade_no}?mchid=1900009191,这个字段里的out_trade_no如果包含特殊字符,必须先做URL编码再拼签名字符串,否则微信那边用同样的规则验签就对不上。请求体摘要是指把请求体(非空时)做SHA256,结果以小写hex字符串形式拼进去,如果请求体为空则拼空字符串。
我常用的工具类是这样一个JAVA方法:
public static String buildAuthorization(String method, String urlPathWithQuery, String body, PrivateKey privateKey, String serialNo, String mchId) throws Exception { long timestamp = System.currentTimeMillis() / 1000; String nonceStr = UUID.randomUUID().toString().replaceAll("-", ""); StringBuilder message = new StringBuilder(); message.append(method).append("\n"); message.append(urlPathWithQuery).append("\n"); message.append(timestamp).append("\n"); message.append(nonceStr).append("\n"); if (body != null && !body.isEmpty()) { message.append(DigestUtils.sha256Hex(body)).append("\n"); } else { message.append("\n"); } Signature sign = Signature.getInstance("SHA256withRSA"); sign.initSign(privateKey); sign.update(message.toString().getBytes(StandardCharsets.UTF_8)); String signature = Base64.getEncoder().encodeToString(sign.sign()); return "WECHATPAY2-SHA256-RSA2048 mchid=\"" + mchId + "\",nonce_str=\"" + nonceStr + "\",timestamp=\"" + timestamp + "\",serial_no=\"" + serialNo + "\",signature=\"" + signature + "\""; }2.2 证书序列号、私钥读取和时间戳的坑
Authorization串里的serial_no是商户API证书的序列号,不是证书内容本身。查法很简单,用openssl一行就能拿到:
openssl x509 -in apiclient_cert.pem -noout -serial输出serial=XXXX,后面的十六进制串就是序列号。
私钥读取建议直接用apiclient_key.pem,它本身就是PKCS8格式,JAVA的PKCS8EncodedKeySpec可以直接解析。如果你想从p12转换私钥,反而容易引入格式问题。我自己在Demo里封了一个MerchantPrivateKeyLoader,路径从配置读取,每次调用时加载一次缓存到内存,避免频繁IO。
时间戳是另一个隐藏坑。签名里的timestamp要求是当前unix秒,微信只接受与服务器时间差在5分钟以内的请求,超时会报TIME_EXPIRED。所以线上服务器一定做好NTP时间同步,我见过云主机时间漂移导致整个支付模块间歇性不可用的案例,排查到最后竟然是指拉取时钟对不上。
提示:所有请求统一走一个封装好的
WxPayClient,把签名、超时、异常封装进去,而不是每个方法各写一遍签名逻辑。这样做的好处是后续接入其他接口时只需要新增一个方法,签名逻辑不会散落到各处。
3. 统一下单、JSAPI唤起支付、支付回调解密,核心支付链路逐个拆
3.1 下单参数与最容易被忽略的总额字段
统一下单是JSAPI支付的第一个远程调用,请求POST /v3/pay/transactions/jsapi,核心参数如下:
{ "appid": "wx8888888888888888", "mchid": "1900009191", "description": "商品描述", "out_trade_no": "MERCHANT_TRADE_NO_20241101", "notify_url": "https://yourdomain.com/api/pay/notify", "amount": { "total": 100, "currency": "CNY" }, "payer": { "openid": "用户的openid" } }amount.total的单位是分,不是元,这个我每次都要强调,因为它导致的Bug比签名错误还多。如果用户支付1.00元,你传的是1而不是100,微信会理解为1分钱。金额精度问题后面退款那里同样严重,会在第5部分展开。
description不能太长,也不能包含恶意字符,它最终会显示在用户的账单和支付凭证上,建议用固定的商品名格式,不要把整个购物车详情塞进去。成交之后如果要修改订单信息,也是通过这个字段做区分。
下单成功后微信返回prepay_id,这是一个预付单标识,有效期2小时。拿到它之后后端要响应给前端,由前端去拉起微信支付。返回结果里同时还有trade_state,但由于下单不等于支付完成,业务上不要用下单响应做任何状态流转,真正信任的只有支付回调。
3.2 让前端能拉起支付的二次签名
前端调起支付用的是wx.chooseWXPayment或新版wx.requestPayment,需要后端生成支付参数并做二次签名。这个签名和请求API v3的签名规则完全不一样,签名字符串是:
appId\n timeStamp\n nonceStr\n package\n \n其中package固定是prepay_id=xxx格式。把这几个参数字符串拼好后,同样用商户私钥做SHA256withRSA签名,把签名结果放到paySign字段。签名用的appId要和下单时一致,否则前端会提示config无效。
后端返回给前端的数据结构大概是:
{ "appId": "wx8888888888888888", "timeStamp": "1730450000", "nonceStr": "f4a9b2c3d4e5", "package": "prepay_id=wx04102233000000", "signType": "RSA", "paySign": "BASE64_SIGNATURE" }3.3 回调验签与AES-256-GCM解密
支付结果是以异步通知的形式推送到你配置的notify_url,微信会往这个地址POST一段JSON,请求头里带Wechatpay-Timestamp、Wechatpay-Nonce、Wechatpay-Signature、Wechatpay-Serial、Request-ID等字段。
第一步是验签,验签规则和请求签名类似,拼串内容是:
时间戳\n 随机串\n 请求体\n \n验签必须用微信支付平台证书的公钥,而不是自己的商户证书。平台证书的序列号对应请求头里的Wechatpay-Serial,你需要根据这个序列号找到对应的平台证书。平台证书有有效期,官方建议自动更新,实际项目中可以用SDK自带的证书自动更新器,或者自己实现一个定时拉取/v3/certificates接口更新证书的调度任务,否则证书过期之后回调验签必然失败。
验签通过后,body里的resource是加密数据:
{ "id": "EV-2024110100000001", "event_type": "TRANSACTION.SUCCESS", "resource_type": "encrypt-resource", "resource": { "algorithm": "AEAD_AES_256_GCM", "ciphertext": "...", "nonce": "加密使用的随机串", "associated_data": "transaction" } }解密用的是API v3密钥(就是商户平台里自己设置的32位密钥),算法是AES-256-GCM。解密后的明文是完整的支付订单数据,包含out_trade_no、transaction_id、amount、payer等字段。拿到明文后,第一件要做的事是业务幂等校验:检查这个out_trade_no在本地是否已经处理过,如果已经处理过直接返回成功,不要重复发货。其次要核对订单金额和本地订单是否一致,防止中间环节被篡改(虽然签名已防御了传输层篡改,但业务侧金额校验依然是必须的)。全部处理完后,响应微信一个200且响应体为{"code":"SUCCESS","message":"成功"},微信收到这个响应才会停止重推通知。
4. 关闭订单、查询订单、查询退款,三个查询/操作接口的边界条件
4.1 关闭订单:只能关没有支付成功的单
关闭订单的请求路径是POST /v3/pay/transactions/out-trade-no/{out_trade_no}/close,请求体里只要传mchid即可。这个接口的语义是"主动使一个商户订单号失效",通常用在用户超时未支付、或者用户主动取消订单的场景。
但有个大坑:已经支付成功的订单不能关闭。如果调用关闭订单接口去关闭一个已支付单,微信会返回ORDERPAID错误。所以在业务代码里,关单前先查一次订单状态,如果已经是SUCCESS或REFUND,就不要走关单逻辑,直接走售后或者正常完成流程。另外,关单后这个out_trade_no不管有没有真的支付成功,都不能再用来发起新的下单了,必须让用户重新生成一个订单号。这也是为什么很多系统里商户订单号都带时间戳或自增ID,而不是用固定业务单据号。
关闭成功后,微信侧这个订单会变成CLOSED状态,前端支付工具里会显示"订单已关闭",用户无法再继续支付。
4.2 查询订单:回调之外的安全网
查询订单的请求路径是GET /v3/pay/transactions/out-trade-no/{out_trade_no}?mchid=xxx(也可以传交易号transaction_id查询)。这个接口返回的字段和支付回调解出来的明文结构几乎一样,都包含trade_state、amount、success_time等。
不要以为配了回调通知就可以不主动查询。实际生产中回调丢包、网络闪断、服务器重启的情况太常见了,所以我的习惯是:订单创建后设置一个延迟任务,比如5分钟或10分钟后主动查询一次订单状态,如果回调没到且查询结果是SUCCESS,则补一次订单更新流程;如果查出来是NOTPAY且超过支付时限,再配合关单逻辑。这种"回调为主、查询兜底"的双保险,能避开很多用户付款后却迟迟不发货的投诉。
trade_state的状态值很多,常见的有SUCCESS、NOTPAY、CLOSED、REVOKED、USERPAYING、PAYERROR、REFUND。注意REFUND表示订单已退款,不是指退款中,退款中的订单trade_state仍可能是SUCCESS。
4.3 查询退款的状态码与轮询策略
查询退款的请求路径是GET /v3/refund/domestic/refunds/{out_refund_no},传的是商户退款单号。返回结构里最重要的字段是status,有四个值:
| 状态 | 含义 |
|---|---|
PROCESSING | 退款处理中,还未到账 |
SUCCESS | 退款成功 |
CLOSED | 退款关闭,通常是原路退款时账户异常等原因 |
ABNORMAL | 退款异常,需要人工介入排查 |
查询退款的结果是异步变化的,一次查询拿不到最终状态,建议在退款发起后做轮询。但轮询要有节制,不要每秒钟打一次微信,我的经验是退款后按 5s、30s、5min、30min 的间隔做几次查询,超过半天还停留在PROCESSING就告警人工介入。微信原路退款的到账速度通常很快,信用卡可能是实时或几分钟,部分银行渠道可能要一两天,所以别因为短时间内没看到SUCCESS就着急重复发起退款。
5. 申请退款与下载对账单:最容易翻车的两个功能
5.1 退款参数、返回值和幂等性缺一不可
申请退款的请求路径是POST /v3/refund/domestic/refunds,核心参数如下:
{ "out_trade_no": "MERCHANT_TRADE_NO_20241101", "out_refund_no": "MERCHANT_REFUND_NO_2024110101", "reason": "用户申请退款", "notify_url": "https://yourdomain.com/api/refund/notify", "amount": { "refund": 100, "total": 100, "currency": "CNY" } }amount.refund是本次退款金额,amount.total是原订单的支付金额,同样都是分。微信会用total校验退款金额不会超额,所以当订单做过多笔部分退款时,必须要保证各次refund之和不超过total,否则会返回金额超限类错误。
退款接口有极强的幂等机制:同一个out_refund_no重复请求,不会造成重复退款,微信会返回第一次退款的结果。这是一把保护伞,退款请求的网络超时千万不要通过"什么都不做"来处理,而要用同一个out_refund_no重试。重试的前提是你在发起退款前就生成了唯一的退款单号并落库,这样无论请求发多少次,最终用户只会收到一笔退款。
退款结果同样通过异步通知推送,通知类型是REFUND.SUCCESS、REFUND.ABNORMAL等,通知体和支付回调一样也是AES-GCM加密,需要先用API v3密钥解密再处理业务。只有把异步通知和主动查询结合起来,退款状态才是可靠的。
5.2 对账单的二次下载与CSV解析
对账单分为交易对账单tradebill和资金对账单fundflowbill,Demo里主要处理交易对账单。流程不是直接GET一个固定文件,而是两步走:
第一步,请求POST /v3/bill/tradebill?bill_date=2024-11-01&bill_type=ALL,拿到:
{ "download_url": "https://api.mch.weixin.qq.com/v3/bill/downloadurl?token=xxx", "hash_type": "SHA1", "hash_value": "30a1d2c3..." }download_url是临时地址,有效期不长,实测基本只有十几分钟,所以不能缓存这个URL,每次对账任务都要重新申请。第二步,直接HTTP GET这个download_url,拿到文件原始字节后先校验SHA1值是否和响应里的hash_value一致,不一致说明下载内容被篡改或传输损坏,不能继续解析。
对账单文件本身是CSV格式,但有三个很烦人的点。第一是编码是GBK/GB2312,不是UTF-8,直接按UTF-8读会乱码;第二是文件开头几行是#开头的注释行,里面包含表头说明;第三是文件末尾几行是汇总统计,不是真实交易明细,解析时要跳过。我用JAVA解析时的核心逻辑大概是这样:
byte[] rawBytes = httpGetBytes(downloadUrl); String sha1 = DigestUtils.sha1Hex(rawBytes); if (!downloadHashValue.equalsIgnoreCase(sha1)) { throw new IllegalStateException("账单hash校验失败"); } String content = new String(rawBytes, charset("GBK")); for (String line : content.split("\n")) { if (line.startsWith("#")) { continue; // 跳过注释行和表头 } String[] cols = line.split(",", -1); // 中间部分才是交易明细 // 遇到统计行(总交易单数开头)时结束 }对账单里的金额单位是元(保留两位小数),和接口里使用的分单位不一样,对账时一定要再做一次单位换算,否则会出现差100倍的惊天Bug。对账的常见做法是把微信账单里的商户订单号和本地的支付记录关联,核对订单金额、手续费、退款金额是否一致,并找出微信侧有但本地没有、本地有但微信侧没有的订单,这些差异订单需要进人工队列处理。
5.3 退款功能和上面对账单的联动
很多团队把退款和对账当成两件独立的事,其实它们强相关。申请退款之后,交易对账单的ALL类型里就会生成对应退款的记录,对账时既要核对支付流水也要核对退款流水。如果退款已经显示SUCCESS,但对账单里没有对应记录,那多半是对账单日期的时区或统计口径问题,也可能是退款发生在账单日边界前后,需要拉前后两天的账单一起核对。
注意:申请退款时填写的
notify_url可以和支付回调分开。如果没有单独的退款通知处理入口,建议至少打日志并落库,退款的成功与否不能只靠前端轮询查询退款接口,而是要以后端落库状态为准。
这套Demo跑下来,我最想提醒你的事
如果只记住一条经验,那就是金额单位。分和元的换算贯穿了下单、查询、退款、对账全部环节,这个坑我至少见人踩过三次。第二条经验是不要把微信支付回调当唯一真相源,回调可能丢,但主动查询不会骗人,回调为主、查询兜底、定时对账这三个手段全部用上,线上支付链路才算稳。第三条是签名相关代码一次写对,后面所有接口都受益,强烈建议把签名封装成公共客户端,Demo里的每个方法调用它,而不是各写一遍签名逻辑。
最后分享一个小技巧:调试阶段可以在微信商户平台的"API安全"里配置仅白名单IP可调用API,防止密钥泄露后被人乱刷。等正式上线,再把这个IP白名单和你的服务器出口IP绑定,配合敏感操作的人工复核,能让这套支付模块更经得起折腾。
本文还有配套的精品资源,点击获取