ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

PHP微信支付V3生产级封装:认证、请求、幂等与验签全解析

PHP微信支付V3生产级封装:认证、请求、幂等与验签全解析 简介本资源是一份面向PHP中高级开发者的微信支付与退款功能实现方案聚焦电商及在线服务类网站的支付闭环需求无需集成微信官方SDK即可快速接入JSAPI支付与原路退款。压缩包共3个PHP文件总大小仅7KB精简实用核心为wxpay.class.php封装统一下单、签名生成、退款请求等逻辑wxpay.php提供调用示例notify.php专用于处理支付与退款异步回调代码结构清晰、注释完整便于理解签名机制、参数组装及状态校验等关键环节。目前已有1008人学习下载适合希望避开SDK复杂配置、深入掌握微信支付底层交互流程的开发者可直接复用核心类并结合业务调整订单号、密钥等参数快速集成到现有项目中。1. 为什么你写的“PHP微信支付和退款类”上线三天就被商户投诉资金异常这不是一个讲SDK怎么安装的入门教程。某开发者在交付一个本地生活SaaS系统时用网上抄来的“通用PHP微信支付类”直接接入微信V3接口结果退款到账延迟超2小时、部分订单重复扣款、回调验签频繁失败——不是代码跑不通而是类的设计逻辑和微信V3真实业务流严重脱节。这个标题里的“类”本质是一套可维护、可审计、可灰度、能扛住微信支付网关抖动与幂等边界条件的业务封装层不是把curl包一层function就叫“类”。它面向的是需要对接微信支付含JSAPI/H5/小程序、处理原路退款、同步查单、异步通知验签、敏感字段加密解密、错误重试退避、日志可追溯的中后台PHP服务。适合正在用ThinkPHP/Laravel/Swoole或纯原生PHP搭建支付中台、SAAS计费模块、会员系统且已申请好微信商户号、配置好APIv3密钥、开通了退款权限的工程师。别急着复制粘贴先看清微信支付V3接口和PHP生态的真实水位线它不接受裸curl、不信任$_POST、不兼容PHP 7.2以下、对时间戳/随机串/签名头格式零容忍。下面带你从零手写一个真正能进生产环境的WechatPayService类。2. 从微信支付V3接口规范反推类结构为什么必须拆成4个核心组件微信支付V3接口不是“调个API就行”它是一套强契约、高安全、多状态的金融级协议。直接写一个WechatPay::pay()方法必然崩盘。我一般会按微信官方文档的能力域隔离原则把整个类拆成四个不可拆分的组件认证器Auth、请求器Request、响应处理器Response、业务门面Facade。这不是炫技是为后续加监控、换日志、切流量、做AB测试留出缝。2.1 认证器用APIv3密钥生成Authorization头不是拼接字符串微信V3要求每个请求头带Authorization: WECHATPAY2-SHA256-RSA2048 ...其值由timestampnonce_strbody三者SHA256哈希后再用商户私钥RSA2048签名。很多人卡在这一步——用openssl_sign()但没设OPENSSL_ALGO_SHA256或body没做JSON标准化空格、换行、键序错乱导致签名不一致。?php class WechatPayAuth { private $mchId; private $serialNo; private $privateKey; public function __construct(string $mchId, string $serialNo, string $privateKeyPath) { $this-mchId $mchId; $this-serialNo $serialNo; $this-privateKey file_get_contents($privateKeyPath); if (!$this-privateKey) { throw new InvalidArgumentException(Failed to load private key from {$privateKeyPath}); } } public function generateAuthHeader(string $method, string $url, string $body ): array { $timestamp (string)time(); $nonceStr bin2hex(random_bytes(16)); // 必须每次请求新生成 $message $method . \n . parse_url($url, PHP_URL_PATH) . \n . $timestamp . \n . $nonceStr . \n . $body . \n; $signature ; $privateKey openssl_pkey_get_private($this-privateKey); openssl_sign($message, $signature, $privateKey, OPENSSL_ALGO_SHA256); openssl_free_key($privateKey); $authHeader sprintf( WECHATPAY2-SHA256-RSA2048 mchid%s,nonce_str%s,signature%s,timestamp%s,serial_no%s, $this-mchId, $nonceStr, base64_encode($signature), $timestamp, $this-serialNo ); return [ Authorization $authHeader, Wechatpay-Serial $this-serialNo, Wechatpay-Timestamp $timestamp, Wechatpay-Nonce $nonceStr, ]; } }参数说明$method必须大写如POST$url必须是完整路径不含域名如/v3/pay/transactions/jsapi$body必须是未格式化JSON字符串无空格、无换行、键名按字典序排列——微信校验严格。$privateKeyPath指向你从微信商户平台下载的apiclient_key.pem文件路径。注意openssl_sign()返回的是二进制签名必须base64_encode()后填入header。2.2 请求器用cURL而非file_get_contents必须控制超时与重试微信支付网关有明确SLA支付下单99%请求300ms退款95%1s。但网络抖动、DNS解析失败、连接池耗尽时裸curl会卡死。必须设置CURLOPT_TIMEOUT_MS非CURLOPT_TIMEOUT并实现指数退避重试最多2次。?php class WechatPayRequest { private $baseUrl https://api.mch.weixin.qq.com; private $httpClient; public function __construct() { $this-httpClient curl_init(); curl_setopt_array($this-httpClient, [ CURLOPT_RETURNTRANSFER true, CURLOPT_HEADER false, CURLOPT_FOLLOWLOCATION false, CURLOPT_SSL_VERIFYPEER true, // 必须开启证书校验 CURLOPT_SSL_VERIFYHOST 2, CURLOPT_CONNECTTIMEOUT_MS 3000, CURLOPT_TIMEOUT_MS 10000, // 总超时10秒微信要求最长15秒 CURLOPT_HTTPHEADER [Content-Type: application/json; charsetutf-8], ]); } public function send(string $method, string $path, array $headers [], string $body ): array { $url $this-baseUrl . $path; $attempts 0; $maxAttempts 2; do { curl_setopt_array($this-httpClient, [ CURLOPT_URL $url, CURLOPT_CUSTOMREQUEST $method, CURLOPT_HTTPHEADER array_merge([Content-Type: application/json; charsetutf-8], $headers), CURLOPT_POSTFIELDS $body, ]); $response curl_exec($this-httpClient); $httpCode curl_getinfo($this-httpClient, CURLINFO_HTTP_CODE); $error curl_error($this-httpClient); if ($response false || $httpCode 0) { if ($attempts $maxAttempts) { usleep((int)pow(2, $attempts) * 100000); // 指数退避100ms, 200ms continue; } throw new RuntimeException(cURL failed after {$maxAttempts} attempts: {$error}); } $responseBody json_decode($response, true); if (json_last_error() ! JSON_ERROR_NONE) { throw new RuntimeException(Invalid JSON response: . substr($response, 0, 200)); } return [ code $httpCode, body $responseBody, headers $this-parseHeaders(curl_getinfo($this-httpClient, CURLINFO_HEADER_OUT)), ]; } while (false); throw new RuntimeException(Unexpected flow in request); } private function parseHeaders(string $rawHeaders): array { $headers []; foreach (explode(\r\n, trim($rawHeaders)) as $line) { if (strpos($line, :) ! false) { [$key, $value] explode(:, $line, 2); $headers[trim($key)] trim($value); } } return $headers; } }关键点CURLOPT_SSL_VERIFYPEER必须为true微信强制HTTPSCURLOPT_TIMEOUT_MS设为10000毫秒是平衡成功率与用户体验的血泪经验——设太短丢单设太长用户等不及。usleep()退避时间按2^n * 100ms计算这是微信官方推荐的客户端重试策略。parseHeaders()用于调试时打印原始请求头生产环境可删。3. 支付与退款的核心业务方法如何让unifiedOrder()和refund()真正幂等微信支付V3的“幂等”不是靠传out_trade_no就能解决的。它要求同一笔订单多次调用下单接口必须返回相同prepay_id同一笔退款多次调用退款接口必须返回相同refund_id且不重复扣款。这需要你在类里内置状态机和本地缓存哪怕只是Redis而不是把责任全甩给微信。3.1 下单接口JSAPI支付必须带openid且notify_url要带商户号参数微信JSAPI支付要求payer.openid字段且notify_url必须是HTTPS、可公网访问、且不能带?参数微信不支持带query的回调地址。常见翻车点用$_SERVER[HTTP_REFERER]拼notify_url或把测试域名写死在代码里。?php class WechatPayService { private $auth; private $request; private $cache; // PSR-16 cache interface, e.g. Redis public function __construct(WechatPayAuth $auth, WechatPayRequest $request, $cache null) { $this-auth $auth; $this-request $request; $this-cache $cache ?: new ArrayCache(); // fallback } public function unifiedOrder(array $params): array { // 1. 强校验必填字段 $required [appid, mchid, description, out_trade_no, amount, payer, notify_url]; foreach ($required as $field) { if (!isset($params[$field])) { throw new InvalidArgumentException(Missing required field: {$field}); } } // 2. 构建请求体微信要求键名小写、顺序固定 $body [ appid $params[appid], mchid $params[mchid], description $params[description], out_trade_no $params[out_trade_no], amount [ total (int)$params[amount][total], currency $params[amount][currency] ?? CNY, ], payer $params[payer], // 必须含 openid notify_url $params[notify_url], // 必须HTTPS且不能带?参数 ]; // 3. 幂等控制检查本地是否已存在该out_trade_no的prepay_id $cacheKey wxpay:order: . $params[out_trade_no]; $cached $this-cache-get($cacheKey); if ($cached isset($cached[prepay_id]) $cached[status] success) { return $cached; } // 4. 发起请求 $headers $this-auth-generateAuthHeader(POST, /v3/pay/transactions/jsapi, json_encode($body)); $result $this-request-send(POST, /v3/pay/transactions/jsapi, $headers, json_encode($body)); if ($result[code] ! 200) { throw new RuntimeException(Wechat pay failed: . json_encode($result[body])); } // 5. 提取prepay_id并缓存TTL 2小时覆盖微信预支付有效期 $prepayId $result[body][prepay_id] ?? null; if (!$prepayId) { throw new RuntimeException(No prepay_id in response); } $this-cache-set($cacheKey, [ prepay_id $prepayId, status success, created_at time(), ], 7200); return [ prepay_id $prepayId, timestamp (string)time(), nonce_str bin2hex(random_bytes(16)), package prepay_id{$prepayId}, sign_type RSA, ]; } }参数说明$params[payer]必须是[openid xxx]格式$params[amount]必须是整数分如100代表1元$params[notify_url]建议用路由生成器动态拼例如https://yourdomain.com/api/wechat/notify?mchid123456789然后在回调入口里用$_GET[mchid]区分多商户。缓存TTL7200是硬性要求——微信prepay_id2小时过期缓存比它短会导致重复下单。3.2 退款接口必须校验原订单状态且out_refund_no要全局唯一微信退款要求1原订单必须是SUCCESS状态2退款金额≤原订单金额3同一out_refund_no只能成功一次。很多人忽略第1条直接调退款结果微信返回ORDERNOTEXIST或REFUNDAMOUNTERROR。?php public function refund(array $params): array { // 1. 校验参数 $required [out_trade_no, out_refund_no, amount]; foreach ($required as $field) { if (!isset($params[$field])) { throw new InvalidArgumentException(Missing refund field: {$field}); } } // 2. 查询原订单状态微信要求退款前必须确认订单有效 $orderStatus $this-queryOrder($params[out_trade_no]); if ($orderStatus[status] ! SUCCESS) { throw new InvalidArgumentException(Cannot refund order with status: {$orderStatus[status]}); } // 3. 构建退款请求体 $body [ out_trade_no $params[out_trade_no], out_refund_no $params[out_refund_no], amount [ refund (int)$params[amount][refund], total (int)$params[amount][total], currency $params[amount][currency] ?? CNY, ], reason $params[reason] ?? 用户申请退款, notify_url $params[notify_url] ?? https://yourdomain.com/api/wechat/refund-notify, ]; // 4. 幂等检查refund_no是否已存在 $refundCacheKey wxpay:refund: . $params[out_refund_no]; $cachedRefund $this-cache-get($refundCacheKey); if ($cachedRefund $cachedRefund[status] success) { return $cachedRefund; } // 5. 调用微信退款接口 $headers $this-auth-generateAuthHeader(POST, /v3/pay/transactions/out-refunds, json_encode($body)); $result $this-request-send(POST, /v3/pay/transactions/out-refunds, $headers, json_encode($body)); if ($result[code] 200) { $refundId $result[body][refund_id] ?? null; if (!$refundId) { throw new RuntimeException(No refund_id in refund response); } $this-cache-set($refundCacheKey, [ refund_id $refundId, status success, created_at time(), ], 86400); // 退款ID长期有效缓存1天 return [ refund_id $refundId, out_refund_no $params[out_refund_no], status success, ]; } // 微信退款可能返回422业务校验失败需透出具体错误 if ($result[code] 422) { $err $result[body][code] ?? UNKNOWN_ERROR; $msg $result[body][message] ?? Unknown error; throw new RuntimeException(Wechat refund failed ({$err}): {$msg}); } throw new RuntimeException(Wechat refund HTTP error: {$result[code]}); }关键逻辑queryOrder()方法必须先调用/v3/pay/transactions/id/{transaction_id}或/v3/pay/transactions/out-trade-no/{out_trade_no}查单确保原订单是SUCCESS。out_refund_no必须由你的系统生成如date(YmdHis) . rand(1000,9999)且全局唯一——微信不接受重复out_refund_no哪怕对应不同订单。缓存TTL86400是因为refund_id永久有效但退款状态可能变更如转为ABNORMAL所以实际业务中还需定时查单同步状态。4. 回调验签与解密为什么90%的“验签失败”都栽在Wechatpay-Serial头和响应体格式上微信所有异步回调支付成功、退款成功都带两个关键头Wechatpay-Timestamp、Wechatpay-Nonce、Wechatpay-Signature、Wechatpay-Serial。而最常被忽略的是Wechatpay-Serial——它标识本次回调用的是哪个平台证书你必须用对应的公钥验签。很多人用错证书或把整个响应体当$body传给验签函数却忘了微信回调的$body是原始二进制不是JSON且$message拼接规则和请求时完全不同。4.1 支付回调验签先取原始body再拼message微信回调的$_POST为空必须用file_get_contents(php://input)读原始数据。且$message拼接格式为${wechatpay-timestamp}\n${wechatpay-nonce}\n${response-body}\n注意末尾换行。?php public function verifyNotify(string $rawBody, array $headers): bool { // 1. 提取必要header $timestamp $headers[Wechatpay-Timestamp] ?? ; $nonce $headers[Wechatpay-Nonce] ?? ; $signature $headers[Wechatpay-Signature] ?? ; $serial $headers[Wechatpay-Serial] ?? ; if (!$timestamp || !$nonce || !$signature || !$serial) { return false; } // 2. 获取对应序列号的平台证书需提前从微信下载并缓存 $certPem $this-getPlatformCert($serial); if (!$certPem) { return false; } // 3. 拼接待验签字符串注意rawBody是原始二进制不能json_decode $message $timestamp . \n . $nonce . \n . $rawBody . \n; // 4. 验签 $pubKey openssl_pkey_get_public($certPem); $ok openssl_verify($message, base64_decode($signature), $pubKey, OPENSSL_ALGO_SHA256); openssl_free_key($pubKey); return $ok 1; } private function getPlatformCert(string $serial): ?string { // 实际项目中这里应从Redis或文件缓存读取证书 // 证书需定期更新微信每3个月轮换建议用定时任务自动拉取 $certPath /path/to/certs/{$serial}.pem; return file_exists($certPath) ? file_get_contents($certPath) : null; }血泪经验$rawBody必须是原始输入不能json_decode($rawBody, true)后再拼——微信验签用的是原始字节流。$message末尾的\n是硬性要求漏掉就100%失败。getPlatformCert()必须支持多证书微信可能同时启用多个serial且证书要存本地文件不能每次HTTP拉否则高并发下IO打满。微信平台证书有效期90天必须监控过期时间并自动刷新。4.2 退款回调解密用AES-256-GCM不是简单base64微信退款回调的resource字段是AES-256-GCM加密的JSON需用$associated_data和$nonce解密。很多人用错算法如用AES-128-CBC或把$nonce当$iv用或忽略$associated_data。?php public function decryptResource(array $resource, string $apiv3Key): ?array { if (!isset($resource[algorithm], $resource[ciphertext], $resource[nonce], $resource[associated_data])) { return null; } if ($resource[algorithm] ! AEAD_AES_256_GCM) { return null; } $ciphertext base64_decode($resource[ciphertext]); $nonce base64_decode($resource[nonce]); $aad base64_decode($resource[associated_data]); // AES-256-GCM解密 $decrypted openssl_decrypt( $ciphertext, aes-256-gcm, $apiv3Key, OPENSSL_RAW_DATA, $nonce, $aad ); if ($decrypted false) { return null; } $data json_decode($decrypted, true); if (json_last_error() ! JSON_ERROR_NONE) { return null; } return $data; } // 使用示例 // $rawBody file_get_contents(php://input); // $headers getallheaders(); // if ($this-verifyNotify($rawBody, $headers)) { // $notifyData json_decode($rawBody, true); // $decryptData $this-decryptResource($notifyData[resource], $this-apiv3Key); // if ($decryptData $decryptData[refund_status] SUCCESS) { // // 更新本地订单状态 // } // }参数说明$apiv3Key是你在微信商户平台设置的32位APIv3密钥不是APIv2的key必须严格保密。openssl_decrypt()第4个参数是OPENSSL_RAW_DATA第5个是$nonce第6个是$aad——三者缺一不可。解密失败时openssl_decrypt()返回false不要直接json_decode()。5. 避坑那些让线上支付服务凌晨三点告警的5个真实问题别等线上出事才看日志。以下是我在三个不同项目中踩过的坑每一条都附带现象、根因和可落地的解决方案。5.1 现象退款成功后用户收不到钱微信商户平台显示“退款异常”原因调用退款接口时amount.total传了字符串100而非整数100微信后端解析失败但返回HTTP 200refund_id却为空。解决在refund()方法开头强制类型转换$params[amount][refund] (int)$params[amount][refund]; $params[amount][total] (int)$params[amount][total];并在日志中记录$params[amount]原始值便于排查。5.2 现象支付回调验签通过但$rawBody解析出的resource字段为空原因Nginx配置了client_max_body_size 1M而微信回调体可能超1MB尤其含大量商品信息时导致body被截断。解决在Nginx配置中增加location /api/wechat/notify { client_max_body_size 5M; fastcgi_buffer_size 128k; fastcgi_buffers 4 256k; }并用strlen($rawBody)校验长度小于100字节即告警。5.3 现象同一out_trade_no多次调用unifiedOrder()返回不同prepay_id原因缓存使用了memcached但未设置serialize选项导致ArrayCachefallback生效而ArrayCache不支持跨进程共享。解决强制使用Redis缓存并在构造函数中校验if (!$cache instanceof \Psr\SimpleCache\CacheInterface) { throw new InvalidArgumentException(Cache must implement PSR-16); }5.4 现象WechatPayAuth::generateAuthHeader()偶尔生成重复nonce_str原因bin2hex(random_bytes(16))在某些PHP版本如7.3以下的random_bytes()可能返回弱熵导致碰撞。解决改用更健壮的生成方式$nonceStr bin2hex(openssl_random_pseudo_bytes(16)); if (!$nonceStr) { $nonceStr bin2hex(random_bytes(16)); }5.5 现象退款回调解密后$decryptData[refund_status]始终为PROCESSING原因微信退款是异步流程PROCESSING表示正在处理需等待5-30分钟但业务代码误以为失败触发重试导致微信重复退款。解决在回调处理中对PROCESSING状态不做任何DB更新仅记录日志并启动一个5分钟后的延迟任务查单if ($decryptData[refund_status] PROCESSING) { // 推送延迟任务到队列5分钟后执行 queryRefund($out_refund_no) $this-delayQueue-push(check_refund_status, [ out_refund_no $out_refund_no, retry_count 0, ], 300); // 5分钟 }6. 进阶技巧用phpstan和psalm静态分析守住支付类的类型安全红线支付类一旦出错就是资金事故不能只靠单元测试。我坚持在CI中加入静态分析堵住90%的低级错误。比如unifiedOrder()的$params[amount][total]必须是int但PHP默认是stringjson_encode()后微信会拒收。6.1 用PHPStan定义严格类型契约在phpstan.neon中添加parameters: level: 8 paths: - src/WechatPayService.php typeToStrictTypes: int: [int, integer] string: [string] checkAlwaysTrueCheckTypeFunctionCall: true然后在类方法注释中写明/** * param array{ * appid: string, * mchid: string, * description: string, * out_trade_no: string, * amount: array{total: int, currency?: string}, * payer: array{openid: string}, * notify_url: string * } $params * return array{prepay_id: string, timestamp: string, nonce_str: string, package: string, sign_type: string} */ public function unifiedOrder(array $params): array运行vendor/bin/phpstan analyse它会立刻报错Parameter #1 $params of method WechatPayService::unifiedOrder() expects array{amount: array{total: int, ...}, ...}, parent type array given.逼你把$params[amount][total] (int)$_POST[total]写进代码而不是靠“应该没问题”的侥幸。6.2 用Psalm检测数组键缺失风险Psalm能发现$params[payer][openid]可能不存在的问题。在psalm.xml中projectFiles directory namesrc / ignoreFiles directory namevendor / /ignoreFiles /projectFiles并在方法上加/** * psalm-param array{payer: array{openid: string}} $params */当有人传[payer []]时Psalm直接标红“Missing keyopenidin array”。6.3 给关键方法加throws文档让IDE自动提示异常链/** * throws InvalidArgumentException 当参数缺失或格式错误 * throws RuntimeException 当微信接口返回非200或JSON解析失败 * throws RuntimeException 当验签失败或解密失败 */ public function verifyNotify(string $rawBody, array $headers): bool这样在调用处PHPStorm会显示所有可能抛出的异常提醒你try-catch而不是让Exception一路冒泡到顶层500。最后说一句我写这个类的第7版时把WechatPayService拆成了WechatPayV3Client纯HTTP、WechatPayDomain业务规则、WechatPayGateway适配器因为某次微信突然升级了证书格式只改了一个适配器就全量切流。支付类不是越“全能”越好而是越“可替换”越稳。希望帮到你。本文还有配套的精品资源点击获取
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表