尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

微信支付JSAPI支付实战:从统一下单到退款对账的完整Demo解析

微信支付JSAPI支付实战:从统一下单到退款对账的完整Demo解析 简介微信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}?mchid1900009191这个字段里的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输出serialXXXX后面的十六进制串就是序列号。私钥读取建议直接用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_idxxx格式。把这几个参数字符串拼好后同样用商户私钥做SHA256withRSA签名把签名结果放到paySign字段。签名用的appId要和下单时一致否则前端会提示config无效。后端返回给前端的数据结构大概是{ appId: wx8888888888888888, timeStamp: 1730450000, nonceStr: f4a9b2c3d4e5, package: prepay_idwx04102233000000, 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}?mchidxxx也可以传交易号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和资金对账单fundflowbillDemo里主要处理交易对账单。流程不是直接GET一个固定文件而是两步走第一步请求POST /v3/bill/tradebill?bill_date2024-11-01bill_typeALL拿到{ download_url: https://api.mch.weixin.qq.com/v3/bill/downloadurl?tokenxxx, 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绑定配合敏感操作的人工复核能让这套支付模块更经得起折腾。本文还有配套的精品资源点击获取
返回列表