PHP微信支付与退款实战:签名、证书、回调解密避坑指南 简介面向PHP开发者的微信支付与退款功能实现方案聚焦电商、在线服务等常见场景下JSAPI支付与退款核心流程不依赖官方SDK自行封装接口调用整体接入更轻量、可控。压缩包共3个php文件总体积仅7KB分别负责支付退款主逻辑、参数封装与签名生成、异步通知处理结构清晰便于直接复用与二次扩展。已有1008人学习下载适合希望快速掌握微信支付对接流程的初中级PHP开发者。代码围绕JSAPI支付完整链路展开涵盖统一下单获取prepay_id、生成JSAPI支付签名、前端调起支付等关键步骤同时实现退款申请、退款状态查询与回调通知解析并演示了XML数据解析及订单状态更新等业务处理。通过阅读这份实现能够理解微信支付接口交互细节与安全签名机制在此基础上根据业务需求进行功能调整和优化。1. PHP微信支付和退款类为什么我劝你别再写“万能支付类”做 PHP 后端的人迟早要碰微信支付。不管你是在给商城接付款还是给 SaaS 做订单结算最后都会搜到“PHP微信支付和退款类”这个词。市面上的轮子很多有官方 SDK也有各种二次封装的“微信支付类”但真正落地时你会发现支付类不是拿来即用的黑匣子而是需要你按业务场景裁剪的半成品。退款尤其如此它不像付款那样一笔请求就能闭环涉及回调、重复退款、部分退款、原路退回等一系列边界问题。这篇文章面向的是要真正把支付和退款跑通的开发者。我会从类设计讲起再给出一份可抄作业的支付与退款核心代码最后把证书、回调、幂等、金额精度这些坑一个一个拆开。适合人群已经能写 PHP但对微信支付 API 不熟或者在联调时被 signature 错误、证书加载失败折磨过的人。2. 支付类的核心设计先拆请求、签名与回调再谈业务很多新手拿到一个支付类第一件事就是找pay()方法传个订单号就完事。这样写出来的代码在联调环境能跑通一上生产就翻车。原因很简单支付类不是单一方法而是围绕 API v3 协议的一组能力组合。你至少需要拆出客户端初始化、请求签名、响应验签、回调解密、业务处理五个层面才能让支付逻辑真正可维护。2.1 为什么 API v3 的“签名-验签”结构决定了类的骨架微信支付 API v3 和 v2 最大的区别是全面切换到了证书与敏感信息加密体系。每次请求都要用商户私钥对请求做 RSA-SHA256 签名响应回来要用微信支付平台证书做验签回调里涉及手机号、银行卡等敏感字段还要用平台证书解密。这个机制决定了你不可能在类里只写一个request()方法就完事。签名过程看起来复杂核心就是三步构造签名串 → 用商户私钥加密 → 拼到 Authorization 头里。签名串格式固定为HTTP方法\n 请求路径\n 请求时间戳\n 随机字符串\n 请求体\n注意换行符不能省略请求体是 JSON 字符串GET 请求时为空字符串。这里最容易出错的是路径不带域名比如/v3/pay/transactions/native很多人在拼签名串时把完整 URL 塞进去结果 signature 错误。2.2 封装基础类请求、签名、验签一把梭我一般会把底层请求能力封成一个WechatPayClient它只负责发请求和验签不关心业务。这样做的好处是支付、退款、账单下载都能复用同一套签名逻辑。?php class WechatPayClient { private string $mchId; // 商户号 private string $serialNo; // 商户证书序列号 private string $privateKey; // 商户私钥文件路径 private string $platformCert; // 微信支付平台证书用于验签 private string $apiBase https://api.mch.weixin.qq.com; public function __construct(string $mchId, string $serialNo, string $privateKey, string $platformCert) { $this-mchId $mchId; $this-serialNo $serialNo; $this-privateKey $privateKey; $this-platformCert $platformCert; } /** * 发起 GET 或 POST 请求自动完成签名 */ public function request(string $method, string $path, array $data []): array { $url $this-apiBase . $path; $body $method GET ? : json_encode($data, JSON_UNESCAPED_UNICODE); $timestamp time(); $nonce $this-generateNonce(); $signStr $method . \n . $path . \n . $timestamp . \n . $nonce . \n . $body . \n; openssl_sign($signStr, $signature, file_get_contents($this-privateKey), sha256WithRSAEncryption); $authorization sprintf( WECHATPAY2-SHA256-RSA2048 mchid%s,nonce_str%s,signature%s,timestamp%d,serial_no%s, $this-mchId, $nonce, base64_encode($signature), $timestamp, $this-serialNo ); $ch curl_init($url); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER true, CURLOPT_CUSTOMREQUEST $method, CURLOPT_POSTFIELDS $body, CURLOPT_HTTPHEADER [ Accept: application/json, Content-Type: application/json, User-Agent: . $_SERVER[HTTP_USER_AGENT] ?? PHP, Authorization: . $authorization, ], ]); $response curl_exec($ch); if (curl_errno($ch)) { throw new RuntimeException(cURL 请求失败: . curl_error($ch)); } curl_close($ch); $decoded json_decode($response, true); if (isset($decoded[code])) { throw new RuntimeException(微信支付 API 错误: . $decoded[message]); } return $decoded; } private function generateNonce(): string { return bin2hex(random_bytes(16)); } }这段代码里最值得看的是request()方法的$signStr拼接。$path必须以/开头且不能包含 query string比如/v3/pay/refunds是对的/v3/pay/refunds?limit10是错的。另外file_get_contents($this-privateKey)每次请求都会读文件性能不好我建议在构造函数里openssl_pkey_get_private(file_get_contents(...))一次后面直接复用它。2.3 支付类能接受哪些参数从下单到支付结果查询基础客户端准备好后支付业务类就变得清晰了。以 Native 支付为例下单只需要像下面这样寥寥几行?php class WechatPay { private WechatPayClient $client; public function __construct(WechatPayClient $client) { $this-client $client; } /** * Native 下单返回 code_url用于生成二维码 */ public function nativePay(string $outTradeNo, int $amountFen, string $description): string { $data [ appid wx1234567890abcdef, mchid $this-client-mchId, description $description, out_trade_no $outTradeNo, notify_url https://your-domain.com/wechat/notify, amount [ total $amountFen, // 单位是分 currency CNY, ], ]; $result $this-client-request(POST, /v3/pay/transactions/native, $data); return $result[code_url]; } }下单接口返回的code_url是用于生成二维码的字符串。注意这里的金额$amountFen必须是整数分传 10.5 元就要传 1050。很多人在这里直接传intval(10.5 * 100)浮点数计算会出现 1049 或 1050 的偏差正确做法是先转换成字符串再运算或者用bcmul(10.5, 100, 0)。支付结果不能靠下单接口返回必须等异步回调通知。回调里有两个关键动作验签和解密resource字段。微信支付的回调数据里resource是加密过的要用 APIv3 密钥解密后才能看到订单状态。这个逻辑在退款回调里完全一样建议放在公共方法里。3. 退款 API 的完整落地从单笔退款到 Partially Refund退款比支付更磨人。支付只有一种成功状态退款却有SUCCESS、PROCESSING、CLOSED、ABNORMAL四种状态而且异步通知可能延迟、可能重复。如果代码里不做状态机和幂等处理用户点一次退款按钮你可能扣两次钱。3.1 退款接口传什么订单号、退款单号、金额与原因微信支付退款接口POST /v3/refund/domestic/refunds支持按原商户订单号退款也支持按微信支付订单号退款。核心参数不复杂但有一个容易被忽略的点退款单号是商户自己生成的退款请求本身不是幂等的。如果同一退款单号发两次请求第二次会直接报错而不会返回原退款结果。?php class WechatRefund { private WechatPayClient $client; public function __construct(WechatPayClient $client) { $this-client $client; } /** * 发起退款 * * param string $outTradeNo 商户订单号 * param string $outRefundNo 商户退款单号 * param int $refundAmtFen 退款金额分 * param int $totalAmtFen 原订单总金额分 * param string $reason 退款原因 */ public function refund(string $outTradeNo, string $outRefundNo, int $refundAmtFen, int $totalAmtFen, string $reason): array { $data [ out_trade_no $outTradeNo, out_refund_no $outRefundNo, reason $reason, notify_url https://your-domain.com/wechat/refund-notify, amount [ refund $refundAmtFen, total $totalAmtFen, currency CNY, ], ]; $result $this-client-request(POST, /v3/refund/domestic/refunds, $data); // 解析退款状态 return [ refund_id $result[refund_id], status $result[status], // SUCCESS / PROCESSING / CLOSED / ABNORMAL user_received $result[user_received_account], // 用户实际到账账户脱敏 ]; } }这里的$totalAmtFen必须和原订单支付金额一致否则微信会返回INVALID_REQUEST。部分退款场景下退款累计金额不能超过原订单金额这个限制不是微信侧强校验而是业务侧必须自己控制的——因为同一订单可以多次发起部分退款微信不会主动帮你拦住超额。3.2 查询退款状态为什么不能只信回调回调通知不是每笔退款都能准时到达的。我在生产环境遇到最多的情况是用户退款成功但回调延迟了十几分钟前端一直显示“退款处理中”用户来投诉。所以退款功能必须提供一个主动查询接口在回调没来时兜底。?php /** * 按退款单号查询退款结果 */ public function queryByOutRefundNo(string $outRefundNo): array { $path /v3/refund/domestic/refunds/ . $outRefundNo; $result $this-client-request(GET, $path); return [ status $result[status], refund_id $result[refund_id], amount $result[amount][refund], // 如果要查高金额退款的用户到账信息可再调 /v3/refund/domestic/refunds/{refund_id}/account ]; }建议在数据库退款表里加一个status字段初始为PENDING。收到回调或查询结果为SUCCESS时更新为SUCCESS查询到PROCESSING则继续轮询。轮询策略不要太激进我一般是首次查询间隔 10 秒之后每 30 秒一次最多查 5 次超过后置为FAILED并通知人工介入。这样既不会把微信接口打爆又能保障用户体验。3.3 退款金额精度的记账处理分还是元退款涉及一个非常现实的问题数据库里的订单金额用什么单位存。如果你用 DECIMAL(10,2) 存“元”那退款请求前要intval($amount * 100)高位运算会出问题。我的建议是金额字段一律存“分”用 BIGINT 类型展示层再除以 100 转成元。这样退款逻辑里完全没有浮点数不会出现 10.55 元退成 10.54 元的“血泪经验”。4. 证书、密钥与回调的 4 个高频坑签名错误、解密失败从哪排查这一章专门写给那些已经把上面的代码抄到本地却怎么都调不通的人。微信支付的文档很全但报错信息往往很简短尤其是“提示用户态签名signature错误”这类问题几乎每个人都会遇到。4.1 证书路径找不到或私钥格式不正确file_get_contents()加载私钥时报openssl_sign(): supplied key param cannot be coerced into a private key一般是两个原因一是路径写错了PHP 进程的工作目录和 CLI 脚本目录不一致二是下载的apiclient_key.pem文件被编辑器偷偷改了换行符。解决办法私钥文件必须和代码放在同一个项目目录并且用绝对路径加载。比如$this-privateKey __DIR__ . /certs/apiclient_key.pem;拿到apiclient_key.pem后不要用记事本打开保存它会强制转成 UTF-8 with BOM导致私钥解析失败。建议用openssl_pkey_get_private()加载时打印一下返回值返回false就说明文件确实有问题。4.2 回调验签失败平台证书过期或没更新微信支付平台证书每半年左右更新一次而且更新是“平滑替换”新旧证书在一段时间内同时有效。如果代码里写死了旧版证书文件的路径新证书生效后验签会失败。现象回调日志里频繁出现Wechatpay-Signature验签 error但订单支付其实成功了。解决微信提供了GET /v3/certificates接口获取平台证书按公钥 ID 和生效时间存储。我在生产环境的做法是做一个定时任务每天凌晨调一次该接口把新证书写入文件验签时遍历所有平台证书尝试有一个通过就算验签成功。这段逻辑不复杂但在生产里极其重要代码示意?php /** * 从微信拉取平台证书并保存 */ public function syncPlatformCerts(): void { $result $this-client-request(GET, /v3/certificates); foreach ($result[data] as $item) { // $item[encrypt_certificate] 需要解密 $aesKey $this-apiV3Key; // APIv3 密钥 $decrypted $this-decryptCallbackData($item[encrypt_certificate]); file_put_contents(__DIR__ . /certs/platform_ . $item[serial_no] . .pem, $decrypted); } }4.3 回调解密失败resource字段解不出明文很多人在处理支付/退款回调时直接从$data[resource]里取ciphertext去解密结果返回AEAD_AES_256_GCM解密失败。原因是证书序列号用的是平台证书的序列号验签解密用的是商户 APIv3 密钥不是同一个东西。回调解密的关键点是?php /** * 解密回调中的 resource 字段 */ public function decryptCallbackData(array $resource): array { $ciphertext base64_decode($resource[ciphertext]); $nonce $resource[nonce]; $associatedData $resource[associated_data]; // 拼接加密串associated_data nonce ciphertext注意顺序 $decrypted openssl_decrypt( $ciphertext, aes-256-gcm, $this-apiV3Key, OPENSSL_RAW_DATA, $nonce, $associatedData ); if ($decrypted false) { throw new RuntimeException(回调数据解密失败); } return json_decode($decrypted, true); }openssl_decrypt的$tag参数在 PHP 7.1 默认输出到第九个参数里不用自己传。这里最容易犯错的是漏传$associatedData或者传成 JSON 字符串而不是原始字符串。一旦解密失败微信会在几秒内重试回调你可以在调试时临时打印$resource里的nonce和associated_data来确认数据没问题。4.4 退款回调与支付回调 URL 撞在一起如果你偷懒把支付通知和退款通知配成同一个 URL业务逻辑会非常混乱因为两种回调的字段结构不同。支付回调里event_type是TRANSACTION.SUCCESS退款回调是REFUND.SUCCESS。混在一个接口里还要先判断类型再分流出错的概率成倍增加。我的建议是notify_url分开配$data [ notify_url https://your-domain.com/wxpay/notify/pay, // 退款时 notify_url https://your-domain.com/wxpay/notify/refund, ];并且在商城里对回调 URL 做签名校验防止别人伪造请求打到你的接口上。虽然微信 HTTP 头里已经带了签名但总有人直接file_get_contents(php://input)拿数据就处理业务这是生产事故级别的疏漏。4.5 部分退款超额被微信拒绝先查累计已退金额部分退款场景里如果同一订单发起了多次退款第三笔时微信会报AMOUNT_ERROR或NOT_ENOUGH。这不是微信把你拒了而是累计退款金额已经等于或超过原订单金额。解决退款前在业务数据库里做一次累加判断SELECT COALESCE(SUM(refund_amount), 0) AS already_refunded FROM refund_records WHERE out_trade_no :tradeNo AND status SUCCESS;?php public function canRefund(string $outTradeNo, int $newRefundAmtFen): bool { $alreadyRefunded $this-querySumRefunded($outTradeNo); $totalPaid $this-queryOrderTotalFen($outTradeNo); return $alreadyRefunded $newRefundAmtFen $totalPaid; }这个canRefund方法在并发场景下要配合数据库行锁或SELECT FOR UPDATE使用否则两个请求同时进来时累计值会算少。我用 Redis 分布式锁兜底保持退款操作串行化凭这个少踩了线上重复扣款的坑。5. 从“能用”到“能上线”支付成功后的订单同步与对账设计很多人把微信支付类跑通后以为万事大吉。但真正上线后你会发现回调通知丢失、重复通知、延迟通知三个问题一定会遇到至少一个。下面说的这套“订单同步 定时对账”机制是我服务过的项目里最稳的兜底方案。5.1 回调处理必须做成幂等重复通知不能导致重复发货微信回调承诺“通知可能重复”并且顺序不作保证。比如支付成功后同一个订单的SUCCESS通知可能发两次如果你在回调处理逻辑里直接更新库存第二次会把库存扣成负数。幂等的实现方式有很多种最简单可靠的是用数据库唯一约束。在交易流水表上建唯一索引CREATE TABLE wx_transactions ( id bigint unsigned NOT NULL AUTO_INCREMENT, transaction_id varchar(64) NOT NULL COMMENT 微信支付单号, out_trade_no varchar(32) NOT NULL COMMENT 商户单号, status varchar(16) NOT NULL DEFAULT PENDING, raw_data json DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_transaction_id (transaction_id) );处理回调时先INSERT IGNORE如果影响行数为 0说明这条通知已经处理过了直接返回成功。?php $stmt $pdo-prepare(INSERT IGNORE INTO wx_transactions (transaction_id, out_trade_no, status) VALUES (?, ?, PROCESSING)); $stmt-execute([$data[transaction_id], $data[out_trade_no]]); if ($stmt-rowCount() 0) { // 重复通知直接返回 echo json_encode([code SUCCESS, message 已处理]); exit; } // 继续更新订单状态、发邮件、发货等业务逻辑...有人问用ON DUPLICATE KEY UPDATE不行吗行但需要额外判断是哪个状态容易写错。INSERT IGNORE配合返回响应逻辑最直观。5.2 支付状态查询兜底回调丢了怎么办回调不是绝对可靠的。微信会重试但重试次数有限一般最多 24 小时。如果回调一直没有成功到达订单永远卡在“未支付”。我一般会在每个支付订单上记录wx_transaction_id然后写一个定时任务每小时扫描所有“已支付但未确认”的订单调用GET /v3/pay/transactions/out-trade-no/{out_trade_no}查询真实状态。?php /** * 查询订单支付状态用于兜底同步 */ public function queryPayStatus(string $outTradeNo): array { $path /v3/pay/transactions/out-trade-no/ . $outTradeNo . ?mchid . $this-client-mchId; $result $this-client-request(GET, $path); $tradeState $result[trade_state]; // SUCCESS / REFUND / NOTPAY / CLOSED / REVOKED / USERPAYING / PAYERROR if ($tradeState SUCCESS) { // 同步订单为已支付 } return $result; }这里有个注意点查询接口不验签吗其实接口返回的数据本身就是微信签名过的WechatPayClient内部如果已经做了响应验签那这里的结果就是可信的。我在基础类里没有验签是因为示例代码精简了真实生产环境建议补上Wechatpay-Signature的验签逻辑至少对金额字段做二次确认防止中间人篡改。5.3 退款表设计记录状态机迁移不直接改业务表退款场景下我建议单独建refund_records表不要直接在主订单表上加refund_status字段因为一个订单可能多次退款。表结构里除了上面提到的字段还必须有一个refund_no唯一键防止重复创建退款单。CREATE TABLE refund_records ( id bigint unsigned NOT NULL AUTO_INCREMENT, refund_no varchar(32) NOT NULL COMMENT 商户退款单号, out_trade_no varchar(32) NOT NULL COMMENT 原商户订单号, refund_amount int unsigned NOT NULL COMMENT 退款金额分, status varchar(16) NOT NULL DEFAULT PENDING, refund_id varchar(64) DEFAULT NULL COMMENT 微信退款单号, created_at datetime DEFAULT CURRENT_TIMESTAMP, updated_at datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_refund_no (refund_no) );状态迁移图建议硬编码在类里public function updateStatus(string $refundNo, string $newStatus): void { $allowedTransitions [ PENDING [PROCESSING, SUCCESS, FAILED], PROCESSING [SUCCESS, ABNORMAL], SUCCESS [], // 最终态 CLOSED [SUCCESS], // CLOSED 是退款关闭部分场景可再退款 ABNORMAL [PROCESSING], // 异常可重试 ]; if (!in_array($newStatus, $allowedTransitions[$this-currentStatus] ?? [])) { throw new RuntimeException(非法退款状态迁移: . $this-currentStatus . - . $newStatus); } // 更新库 }有了这张表之后“用户收到退款成功提示但钱没到账”这类问题就变成了一张 SQL 查得清楚的数据关系而不是靠猜。6. 进阶玩法同一套支付类适配 App、小程序与 PC 扫码最后聊一个扩展点很多人以为 Native 支付只适合 PC 扫码实际上你的支付类可以扩展成多种场景核心参数只有两三处不同。我最近在做的项目里同时接了微信小程序支付、App 支付和 PC 收银台底层WechatPayClient一行没改只是新增了几个方法。6.1 小程序支付换接口、换参数不换签名逻辑小程序支付和 Native 支付一样走 API v3只是下单接口换成POST /v3/pay/transactions/jsapi多传一个payer.openid字段。前端拿到的是prepay_id然后调wx.requestPayment()。签名逻辑、回调逻辑完全复用。?php public function jsapiPay(string $outTradeNo, int $amountFen, string $description, string $openid): array { $data [ appid wx1234567890abcdef, mchid $this-client-mchId, description $description, out_trade_no $outTradeNo, notify_url https://your-domain.com/wechat/notify/pay, amount [ total $amountFen, currency CNY, ], payer [ openid $openid, ], ]; $result $this-client-request(POST, /v3/pay/transactions/jsapi, $data); return $this-buildJsapiParams($result[prepay_id]); } private function buildJsapiParams(string $prepayId): array { // 小程序端调 wx.requestPayment 所需的参数 $timestamp time(); $nonce $this-client-generateNonce(); // 注意这个方法是私有的需要改成 public 或在内部实现 $package prepay_id . $prepayId; $signStr $this-client-mchId . \n . $timestamp . \n . $nonce . \n . $package . \n; // 这里用的是「微信支付商户平台」里的 APIv3 密钥做 HMAC-SHA256不是商户私钥 openssl_sign($signStr, $signature, file_get_contents($this-client-privateKey), sha256WithRSAEncryption); return [ timeStamp (string) $timestamp, nonceStr $nonce, package $package, signType RSA, paySign base64_encode($signature), ]; }6.2 App 支付支付结果由系统回调不是 HTTP 回调安卓和 iOS 接入微信支付时有个天然的差异App 支付成功后微信通过系统级的onResp回调告诉你结果不走 HTTP 异步通知。这意味着服务端不能只依赖回调更新订单状态而是要在客户端拉起支付的同时就创建一个“待确认”订单由客户端把支付结果传回来再以服务端查询为准做最终确认。坑在于 App 端如果在前台杀了进程或者网络波动onResp可能丢失。所以 App 支付的订单同步一定要做“服务端主动查询兜底”而不能像小程序那样单纯等待异步回调。这也是为什么我在上面第五章节花了篇幅讲查询接口——它不只是兜底是 App 支付的唯一可靠来源。6.3 用同一商户号跑多个应用区分 appid 与 sub_appid如果你一个商户号下挂了 App、小程序、公众号三个应用下单接口里appid要对应传各自的应用 ID。这里容易犯的错是把小程序的appid传给 App 支付接口结果微信返回APPID_MCHID_NOT_MATCH。退款接口不用传appid它只管商户号下的交易但查询支付状态接口里GET /v3/pay/transactions/out-trade-no/{out_trade_no}?mchid...却要求带mchid不带则会报参数缺失。最后说一个我自己的习惯每次升WechatPayClient代码后先在测试环境用官方 Postman 那条signature校验请求测一遍确认基础签名没问题再跑业务。这个习惯帮我躲过了不少升级框架后私钥路径被破坏的坑。微信支付类的实现并不复杂复杂度全在细节里。希望这份拆解能帮你少走几个月的弯路。本文还有配套的精品资源点击获取