十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

PHP微信支付与退款实战:API v3签名与幂等设计全解析

PHP微信支付与退款实战:API v3签名与幂等设计全解析 简介面向PHP开发者的微信支付与退款功能集成资源尤其适合电商、在线服务等需要安全收款与自动退款场景采用JSAPI方式并绕过官方SDK降低上手门槛。资源完整覆盖预支付订单生成、JSAPI签名构造、前端wx.chooseWXPay唤起支付以及退款申请、退款状态查询和异步回调通知解析等关键环节能够帮助开发者快速打通“用户付款—订单确认—异常退款—状态同步”的业务闭环。压缩包共3个PHP文件大小仅7KB按“支付主逻辑、公共类封装、回调处理”分层组织代码量精简便于移植到现有ThinkPHP、Laravel等框架中复用。目前已有一千余人学习下载示例包含实际请求参数与返回处理对理解微信支付接口交互、签名算法和回调验签很有参考价值同时附有支付密钥妥善保管等安全提示适合具备基础PHP语法、希望自主实现支付模块的开发者。 做PHP开发这些年微信支付和退款是我觉得最值得较真的一块。很多人以为支付就是调两个接口实际上只要碰到签名、回调、幂等、对账就知道它没那么简单。这篇文章把我实际项目里沉淀下来的一套PHP实现方案完整拆开讲从类结构怎么设计、支付方式怎么选、API v3签名怎么做到退款怎么防重复再到高频报错的排查思路一次性讲透。如果你是刚接手支付模块的后端或者想在老项目里重构一套规范的支付类这篇应该能帮你少走不少弯路。1. 项目整体设计与类结构拆解1.1 先从需求说起为什么要做统一封装微信支付不是一个接口就能搞定的。JSAPI、Native、H5、小程序支付加上服务商模式、分账、退款、账单下载零零总总十几个接口。如果每个业务控制器里直接去拼参数、调curl、解析返回结果后面一旦要升级API版本或者更换证书改起来会非常痛苦。我见过不少项目订单表里直接存prepay_id回调方法里写一大坨XML解析代码退款直接从网上复制一段代码粘贴过来。短期能跑但长期问题很明显代码重复严重改签名逻辑要全局搜索替换没有统一错误处理微信返回错误码时前端只看到一个笼统提示退款没有幂等控制重复点击可能把一笔订单退两次没有规范的日志输出线上出了问题只能干瞪眼。所以我做支付模块的第一件事就是把微信支付能力收敛成一个独立的类库。业务层只面向这个类库调用不需要关心微信API细节也不需要在控制器里堆撒签名逻辑。1.2 整体设计思路统一入口加策略分发我采用的方式是定义一个统一的支付入口内部按支付场景分发到不同处理器。核心是用一个抽象接口把“能做什么”固定下来再用具体实现类去接不同的支付渠道。这里可以简化为四层接口层定义统一能力包括创建订单、查询订单、退款、查询退款、处理回调。实现层微信支付V3实现类持有商户号、证书路径、APIv3密钥。工厂层根据渠道参数返回对应的实现类实例。业务层下订单、退款、回调处理等业务逻辑只依赖接口层。interface PayInterface { public function createOrder(array $params): array; public function queryOrder(string $outTradeNo): array; public function refund(array $params): array; public function queryRefund(string $outRefundNo): array; public function handleNotify(string $rawBody, array $headers): array; }这样的设计并不复杂但把易变的部分全部隔离在实现层里。将来如果业务要接入支付宝只需要新增一个支付宝实现类业务层代码几乎不用动。1.3 核心类划分支付基类、微信子类、退款服务实际项目中我习惯把公共能力抽到基类比如HTTP请求发送、v3签名、回调数据解密。微信支付子类只负责组装业务参数不重复写底层逻辑。退款这块我单独拆了个RefundService而不是把退款逻辑全部塞进支付类里。原因很简单退款有自己独立的状态流转涉及退款单号、退款原因、金额校验、结果补偿它跟下单流程的耦合度很低。拆开之后代码清晰度提升不少。类结构大致是这样的WechatPayBase公共方法包括签名、请求、解密通知WechatPayV3 extends WechatPayBase支付相关接口实现RefundService业务层负责退款校验、幂等控制、更新订单状态PaymentLogService统一记录请求、响应、异常日志。给一个简单的基类签名方法示例protected function buildAuthorization(string $method, string $url, string $body ): string { $timestamp time(); $nonce bin2hex(random_bytes(16)); $message {$method}\n{$url}\n{$timestamp}\n{$nonce}\n{$body}\n; openssl_sign($message, $signature, $this-merchantPrivateKey, OPENSSL_ALGO_SHA256); return sprintf( WECHATPAY2-SHA256-RSA2048 mchid%s,nonce_str%s,timestamp%d,serial_no%s,signature%s, $this-mchId, $nonce, $timestamp, $this-certSerialNo, base64_encode($signature) ); }这个方法几乎贯穿所有v3接口请求签名串格式一个字符都不能错尤其是末尾的换行符。2. 微信支付方式选型与关键参数2.1 常见支付方式使用场景对照微信支付在PHP项目里最常见的几种接入方式我整理了一个对照表支付方式适用场景是否要openid主要参数JSAPI公众号内网页支付需要openid、appid、mchid小程序支付微信小程序内支付需要openid、appid、mchidNativePC网站扫码支付不需要返回code_url生成二维码H5微信外浏览器支付不需要需配置场景信息服务商模式平台多商户收单视子商户而定子商户号、子商户appid选型时不要只看前端体验。JSAPI和小程序支付都需要用户授权拿openid服务端在调用下单接口前必须先把openid取到。Native支付不用openid适合PC端扫码场景但要注意二维码有效期通常只有两个小时超时要重新下单。服务商模式是平台型项目常踩的坑它和普通直连商户的字段差异很大下单要传sub_mchid和sub_appid签名主体还是服务商的商户号但回调通知里的商户号可能是子商户。前期设计时一定要把这些字段透传清楚。2.2 API v2与v3到底怎么选很多老项目还在用API v2新项目我建议直接上v3。理由非常实际v3对回调数据使用AES-256-GCM加密敏感信息不直接暴露在回调报文中v3接口统一走HTTPS加签名不需要像v2那样做双向证书认证v3的请求头签名方式更规范私钥只要自己保管好就行微信支付官方文档在新功能上基本都以v3为主。但这不意味着v2可以完全不管。服务商模式的部分接口、企业付款到零钱、部分营销工具目前还是v2风格。所以我的类库以v3为主对个别必须走v2的接口单独做了一层兼容避免业务方被某一套API绑死。2.3 v3签名与请求头实现细节v3签名串是这种格式HTTP方法\n URL路径\n 时间戳\n 随机串\n 请求体\n然后把签名串用商户私钥做SHA256withRSA签名最终拼到Authorization请求头里。需要注意几点URL不携带域名和查询参数只取路径部分比如/v3/pay/transactions/jsapi请求体为空时签名串里的请求体字段也是空的但换行符不能少随机串每次请求都要重新生成不能用固定值服务端时间必须校准偏差超过五分钟会直接报错。我在排查签名问题时习惯先把待签名串打印出来再用微信官方提供的签名工具比对。只要待签名串和工具算出来的结果一致问题基本都出在传输或密钥上而不是算法本身。3. 支付主流程与回调处理3.1 统一下单与拉起收银台以小程序支付为例服务端调用/v3/pay/transactions/jsapi下单参数包括appid、mchid、description、out_trade_no、notify_url、amount和payer。下单成功后会拿到prepay_id然后后端需要再生成小程序端拉起收银台所需的paySign$params [ appId $this-appId, timeStamp (string) time(), nonceStr $this-nonceStr, package prepay_id{$prepayId}, signType RSA, ]; $message {$params[appId]}\n{$params[timeStamp]}\n{$params[nonceStr]}\n{$params[package]}\n; openssl_sign($message, $signature, $this-merchantPrivateKey, OPENSSL_ALGO_SHA256); $params[paySign] base64_encode($signature);这里最容易错的是package字段很多新手会写成prepay_id不带等号或者把package放到了签名串外面导致小程序端一直报签名错误。另外时间戳在v3支付参数里是字符串类型直接传数字在某些客户端SDK里也会出问题。3.2 回调验签、解密与幂等处理收到微信支付回调后第一步不是更新订单状态而是先验证通知签名。v3回调的验签逻辑是从请求头取出Wechatpay-Timestamp、Wechatpay-Nonce、Wechatpay-Signature、Wechatpay-Serial用平台证书验签通知内容。验签通过后再对resource里的密文做AES-256-GCM解密。解密后的数据是这样的{ out_trade_no: 202501010000001, transaction_id: 4200001234567890, trade_state: SUCCESS, amount: { total: 100 } }拿到trade_state为SUCCESS后必须先做幂等处理。我的做法是在事务里更新订单状态用更新条件做天然锁UPDATE orders SET pay_status 1, transaction_id ?, paid_at NOW() WHERE order_no ? AND pay_status 0如果影响行数为0说明这单已经处理过了直接返回微信成功应答。这个简单操作能挡住大量重复回调带来的重复入账问题。处理完业务逻辑后接口要返回{code:SUCCESS,message:成功}否则微信会按策略反复重试重试次数多了还可能触发风控。3.3 订单状态机的设计细节订单状态不能简单用“未支付”和“已支付”两个值至少要有完整的生命周期待支付、已支付、退款中、已退款、部分退款、已关闭。我习惯在订单表里分开维护pay_status和refund_status再配合一张退款流水表能清晰看到每一笔钱的状态。实际状态流转是待支付可以关单已支付后可以发生退款退款可能是全额也可能是部分退款退款中状态下不允许再次发起新的退款申请退款成功或失败后状态要能回写订单主表。这里我强烈建议不要用0和1两个值走天下后面做运营对账的时候会非常痛苦。4. 退款功能实现与防重复4.1 退款接口的关键参数微信支付v3退款接口是POST /v3/refund/domestic/refunds关键参数包括原商户订单号out_trade_no、退款单号out_refund_no、退款金额amount、退款原因reason、回调地址notify_url。退款单号一定要自己生成并且要保证唯一。我常用的格式是前缀加日期再加随机串比如RF20250101001。这个单号在业务里就是幂等键同一笔退款请求带着同一个out_refund_no重复提交微信只会受理一次。金额字段单位是分不是元。之前有个兄弟项目就是因为单位问题退款金额差了100倍测试环境没发现上线后被用户投诉才发现。所以我在参数校验层会强制转成整数分再传出去并且做一层金额上限校验避免退款金额大于订单实付金额。4.2 退款异步通知与状态补偿调用退款接口成功不代表钱已经退回用户账户了微信只是受理成功。真正的退款结果要等异步通知或者由服务端主动查询。退款回调的验签和解密逻辑与支付回调一致只是事件类型变成了REFUND.SUCCESS和REFUND.CLOSED。我在这块做了一个结果补偿机制收到退款成功通知后更新退款流水表把退款状态改成成功再把订单主表的refund_status同步更新。另加一个定时任务扫描那些长时间停留在“退款申请中”的单子主动调用微信查询接口确认最终状态避免回调丢失导致的数据不一致。4.3 防重复退款与并发控制微信支付允许同一笔订单多次退款但累计退款金额不能超过原订单金额。这个校验在代码里必须有尤其是并发场景。我遇到过两个运营同时在后台操作一笔订单退款两个请求都通过了金额校验最后导致超退。解决办法是给退款申请加一个事务锁。最简单的方式是利用数据库更新条件UPDATE orders SET refund_status REFUNDING WHERE order_id ? AND refund_status NONE如果这条SQL影响行数为0说明订单当前已经有退款流程在进行直接拒绝本次退款申请。等退款回调回来再把refund_status改回允许退款的中间态。这样不需要引入Redis分布式锁也能挡住绝大多数重复操作。5. 常见问题与排查心得5.1 高频报错速查表微信支付接口返回的错误码很多字面意思和实际场景对不上我整理几个高频的错误码常见原因处理建议PARAM_ERROR参数格式不对日期、金额、appid等按接口文档逐个比对字段SIGN_ERROR签名串或私钥不对打印待签名串用官方工具对比ORDERPAID订单已支付重复下单直接查订单走支付成功流程NOTENOUGH商户号余额不足请先充值保证退款可用余额REFUND_FEE_INVALID退款金额超过可退余额检查原订单实付和已退金额SYSTEMERROR微信内部异常稍后重试先查本地日志记录这些错误码在测试环境就很容易暴露。我的习惯是在封装层把微信原始返回的code和message完整记录到日志同时在业务层转成用户看得懂的文案两头都留痕排查时能省不少时间。5.2 调试与日志记录的几个技巧支付模块出问题最怕的是没有任何日志。我每个支付类都强制记录请求前、请求后和异常三个阶段的日志字段包括接口名、请求参数、响应原文、耗时、错误码。这样即使线上出了问题也能根据商户订单号快速定位到哪一步失败。调回调时本地可以用内网穿透工具把回调地址暴露到外网但只建议开发环境使用。生产环境的回调URL必须是HTTPS且不能用IP地址这个在微信支付平台的配置里就有限制。真正上线前我建议把支付和退款相关日志级别临时调到DEBUG用测试商户号跑一遍完整流程确认没有异常再调回INFO。5.3 上线前容易忽略的细节还有一些细节看起来不起眼但踩一次就是事故服务器时间必须同步NTP签名时间戳偏差过大微信直接拒绝请求商户私钥和证书不要提交到代码仓库建议用环境变量或独立的配置文件读取平台证书会定期更换程序里要做自动更新不要写死一版证书用到底回调处理代码里不要做耗时操作比如发短信、推送消息先返回成功再异步处理退款必须走独立的退款单号体系不要在退款时复用原支付订单号所有金额运算用整数分避免浮点运算误差。我自己的体会是支付模块最重要的不是代码写得有多花哨而是可观测性、幂等性和异常兜底。把支付和退款封装好后面接服务商多商户分账、账单下载、日常对账都会顺畅很多。最后再说一个小技巧每次发版前把支付和退款相关的日志级别调成DEBUG跑一遍测试商户全流程确认请求头、签名、回调都是通的能避免大多数线上事故。本文还有配套的精品资源点击获取
返回列表