简介:面向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、mchid |
| Native | PC网站扫码支付 | 不需要 | 返回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跑一遍测试商户全流程,确认请求头、签名、回调都是通的,能避免大多数线上事故。
本文还有配套的精品资源,点击获取