PHP微信支付与退款实战:API v3签名与幂等设计全解析
2026/9/2 22:18:09 网站建设 项目流程

简介:面向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
NativePC网站扫码支付不需要返回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.SUCCESSREFUND.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跑一遍测试商户全流程,确认请求头、签名、回调都是通的,能避免大多数线上事故。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询