尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
微信支付API v3 PHP后端对接实战:签名验签、AES解密与完整支付流程实现
做小程序支付的后端对接PHP 开发者大概率都会在微信支付 API v3 这一关卡上一段时间。官方给的 SDK 主要面向 Java、Go 这些服务端语言PHP 生态里没有一套“官方盖章”的完整实现网上流传的代码又版本各异改起来常常一头雾水。这套 PHP 后端代码是我在实际小程序项目中反复迭代出来的覆盖从统一下单、支付回调验签解密、订单查询到退款申请和退款查询的完整链路。核心的 v3 签名、AES-256-GCM 解密、平台证书管理等关键逻辑都单独封装好了拿过去改改配置就能跑通主流程。如果你正准备接手微信支付 v3 的小程序项目或者被回调验签、证书序列号这些概念绕晕了这篇内容适合你花十几分钟读完。我会从整体代码结构讲到签名算法细节再从下单流程讲到退款闭环最后把我在线上环境踩过的坑和排查思路一并整理出来。1. 整体设计与项目结构拆解1.1 为什么不直接用第三方包我最早也图省事找过几个常见的微信支付 PHP 扩展包。结果是让人头大的v2 时代的老包确实成熟但 v3 的加密模型改动太大很多包直接停止维护个别还在更新的包签名类型、证书序列号读取逻辑在不同版本里来回改升级一次坏一次。后来想明白一件事——v3 的难点不在业务接口而在“签名、验签、解密”这套体系。于是我自己写了一套完整后端代码。核心思路是HTTP 请求层只负责发请求和接响应不掺业务加解密、签名验签单独抽成工具类业务接口层只做参数组装和结果解析这样做的最大好处是万一微信支付更新了某个接口的字段你只改业务层的参数组装签名和证书逻辑完全不用动。1.2 项目目录是怎么划分的整个项目结构我按职责拆得比较干净wechatpay-v3/ ├── config.php # 配置项商户号、appid、证书路径等 ├── pay/ │ ├── create_order.php # 统一下单入口JSAPI │ ├── callback.php # 支付结果回调接收处理 │ ├── query_order.php # 查询订单状态 │ └── refund_request.php # 发起退款 ├── core/ │ ├── WechatPayV3Service.php # 核心服务类封装全部对外方法 │ ├── RsaUtil.php # RSA 签名、验签、敏感信息加密 │ ├── AesGcmUtil.php # AES-256-GCM 解密v3 专属 │ ├── HttpUtil.php # 发送 HTTP 请求组装 Authorization 头 │ └── CertManager.php # 商户证书、平台证书的加载与更新 ├── cert/ │ ├── apiclient_cert.pem # 商户API证书公钥部分 │ ├── apiclient_key.pem # 商户API私钥 │ └── platform_cert.pem # 微信支付平台证书用于验签 └── logs/ └── wechatpay.log # 支付相关日志真正的核心是core/目录下的几个类。业务方调用的时候只需要new WechatPayV3Service($config)不需要关心底层证书加载和签名逻辑。1.3 v2 到 v3 的核心变化理解了你才敢动手v3 和 v2 最大的区别不是请求格式从 XML 变成了 JSON而是安全机制换了底层维度v2老版v3新版请求签名MD5 或 HMAC-SHA256简单粗暴SHA256withRSA商户私钥签名回调内容明文 XML安全隐患大AES-256-GCM 加密密文传输证书机制商户证书上传后基本不验证商户证书 微信平台证书双证书机制敏感字段明文传输指定字段用平台证书公钥 RSA 加密接口风格XML 报文参数平铺JSON REST 风格路径v2 时代签名串就是一堆参数拼起来算个 MD5防御能力有限。v3 用的是非对称签名体系商户用私钥对请求内容签名微信用商户证书公钥验签反过来微信回调时用微信平台证书私钥签名回调内容商户用平台证书公钥验签。整套体系更安全代价就是代码实现复杂了不少——这也正是“PHP 完整后端代码”的价值所在。2. 初始化与最核心的签名加密体系2.1 五个基础配置项缺一个都跑不起来配置这块我踩过一次大坑。当初连平台证书路径都写错了导致回调验签一直失败排查了大半天。所以配置项每个都要搞清楚来源配置项值示例获取方式商户号 mchid1600000000微信商户平台“账户中心”appidwx1234567890abcdef小程序后台“开发管理”商户API私钥 apiclient_key.pem文件路径商户平台“API安全”中生成商户API证书序列号 serial_no大写十六进制串用 openssl 读取证书APIv3 密钥32位随机字符串商户平台“API安全”中设置商户证书序列号很容易搞错。很多人直接复制商户平台网页上显示的证书编号但那是证书管理页的备注编号不是证书本身的序列号。正确做法是用 openssl 直接读openssl x509 -in /path/to/apiclient_cert.pem -noout -serial输出结果里serial后面的那串十六进制才是真正要填进serial_no的值而且需要原样大写。我在代码里加了配置校验如果 serial_no 格式不对包含非十六进制字符或长度异常直接抛异常提示。2.2 HTTP 请求签名是怎么算出来的v3 的每个请求头都要带上Authorization格式是Authorization: WECHATPAY2-SHA256-RSA2048 mchid1600000000,nonce_strxxxx,signaturexxxx,timestamp1700000000,serial_noABCDEF...signature 的计算分三步第一步按规则拼出待签名串。格式如下HTTP方法\n URL路径\n 时间戳\n 随机串\n 请求体\n注意几个细节URL 路径只包含请求路径和 query string不含域名。比如GET /v3/pay/transactions/out-trade-no/abc?mchid1600000000时间戳是秒级的字符串time()可以直接用随机串每次请求都要重新生成不能复用我用bin2hex(random_bytes(16))生成请求体为空时比如 GET 查询body 就是一个空字符串但换行照样需要第二步用商户私钥做 SHA256withRSA 签名$message $method . \n . $urlPath . \n . $timestamp . \n . $nonceStr . \n . $body . \n; openssl_sign($message, $signature, $privateKey, OPENSSL_ALGO_SHA256); $signature base64_encode($signature);第三步拼装 Authorization 头。HttpUtil里我封装了一个buildAuthHeader()方法直接传入 method、urlPath、body 就能输出完整的请求头内容业务层完全不用关心。提示发送请求时用 curl 设置CURLOPT_HTTPHEADERAuthorization 字段必须完整一行不能跨行不能有额外空格。2.3 APIv3 密钥与 AES-256-GCM 解密逻辑回调通知到的内容不是明文而是一个resource对象。典型结构{ resource: { original_type: transaction, algorithm: AEAD_AES_256_GCM, ciphertext: base64字符串, associated_data: 附加数据, nonce: 随机向量 } }解密要用到 APIv3 密钥。这个密钥不是私钥而是你在商户平台自己设置的一串 32 位字符串作用是加密回调内容和加密平台证书内容。AES-256-GCM 解密在 PHP 里用openssl_decrypt实现。关键点是 GCM 模式会把认证标签 tag16字节拼在密文末尾必须手动截取public function decrypt(array $resource): string { $ciphertext base64_decode($resource[ciphertext]); $nonce $resource[nonce]; $aad $resource[associated_data] ?? ; // 取出密文末尾的16字节 tag $tag substr($ciphertext, -16); $ciphertextWithoutTag substr($ciphertext, 0, strlen($ciphertext) - 16); $plaintext openssl_decrypt( $ciphertextWithoutTag, aes-256-gcm, $this-apiV3Key, OPENSSL_RAW_DATA, $nonce, $tag, $aad ); if ($plaintext false) { throw new \RuntimeException(AES-256-GCM 解密失败请检查APIv3密钥是否正确); } return $plaintext; }最容易掉的坑是提前做了 base64 解码然后又把 base64 字符也当成密文去截 tag。密文的 base64 字符串要先完整解码成二进制再从末尾截取 16 字节这个顺序不能乱。2.4 平台证书的下载与自动更新你可能会问为什么要有平台证书商户请求用的是商户证书序列号微信回调验签用的是平台证书两者是独立的。平台证书的获取方式有两种一是从商户平台手动下载二是调用接口自动拉取。接口地址是GET /v3/certificates。返回的证书内容同样被 APIv3 密钥加密需要先用 2.3 节的方法解密。我自己在代码里封装了updatePlatformCertificate()方法用一个定时任务每天凌晨拉取一次把新的平台证书写入 pem 文件同时在日志里记录旧证书过期时间。平台证书也会定期轮换所以回调验签时不能写死用某一个证书的序列号。正确做法是验签之前先读取Wechatpay-Serial请求头和本地已有的平台证书序列号比对不一致时先自动更新再验签。这套流程代码里已经写好线上跑下来比较稳。3. 核心支付流程实操与代码走读3.1 小程序统一下单从参数组装到拿到 prepay_id小程序支付走的是 JSAPI 下单接口路径是POST /v3/pay/transactions/jsapi。这一步在代码里唯一要干的事就是组装参数并签名发出。关键参数参数说明注意事项appid小程序 appid必须和商户号绑定mchid商户号不能抄错description商品描述不要含换行符和特殊符号out_trade_no商户订单号同一商户号下唯一notify_url回调地址必须是 https不能带 query stringamount.total支付金额单位是分整数payer.openid用户在小程序中的 openid和 appid 一一对应代码调用方式use core\WechatPayV3Service; $config require config.php; $service new WechatPayV3Service($config); $params [ appid $config[appid], mchid $config[mchid], description 测试商品, out_trade_no $orderNo, notify_url $config[notify_url], amount [ total 100, // 1元 currency CNY ], payer [ openid $openid ] ]; $result $service-request(POST, /v3/pay/transactions/jsapi, $params); $prepayId $result[prepay_id] ?? ;这里最容易犯的错是金额单位。测试时经常把接口返回的100当成 1 元其实 100 表示 1 元整的 100 分。我自己第一次联调时也调了好几次才发现是单位理解错了服务端日志一直报“金额与订单不一致”。3.2 小程序端拉起支付后端必须做二次签名拿到prepay_id只是第一步。小程序端wx.requestPayment需要的数据不能用下单接口的 response 直接返回必须后端再生成一次paySign。新版要求的签名串和请求头签名不一样字段顺序固定为 appId、timeStamp、nonceStr、package共四个字段$params [ appId $config[appid], timeStamp (string) time(), nonceStr bin2hex(random_bytes(16)), package prepay_id . $prepayId, signType RSA, ]; // 签名串 $message $params[appId] . \n . $params[timeStamp] . \n . $params[nonceStr] . \n . $params[package] . \n; openssl_sign($message, $signature, $privateKey, OPENSSL_ALGO_SHA256); $params[paySign] base64_encode($signature); // 返回给小程序前端 echo json_encode($params, JSON_UNESCAPED_UNICODE);这个签名用的是商户 API 私钥不是 APIv3 密钥。我见过有同事把 APIv3 密钥当私钥去签前端一直报签名错误。两者完全不同。给前端的timeStamp虽然看起来是个数字但建议转成字符串再传避免 JSON 解析变形。前端拿到后直接传给wx.requestPayment的timeStamp字段类型也要是字符串。3.3 支付结果回调的验签与解密完整流程回调通知可能是全流程里最容错率最低的一环。微信通知过来后不能直接读 body 就完事必须严格走完“验签 解密 幂等处理”三步。验签要看的请求头有四个请求头含义Wechatpay-Signature签名值Base64 编码Wechatpay-Timestamp微信生成签名的时间戳Wechatpay-Nonce随机串Wechatpay-Serial微信平台证书序列号验签的签名串构造和发请求时不一样回调验签的待签名串是时间戳\n 随机串\n 原始请求体\n注意最后一个原始请求体后面没有换行。而且必须是微信发来的原始 HTTP body 字符串不能是你用 PHP 解析成数组后再json_encode出来的字符串。PHP 里可以用file_get_contents(php://input)获取原始 body。验签函数$message $headers[wechatpay_timestamp] . \n . $headers[wechatpay_nonce] . \n . $body; $publicKey openssl_pkey_get_public(file_get_contents($config[platform_cert])); $result openssl_verify($message, base64_decode($headers[wechatpay_signature]), $publicKey, OPENSSL_ALGO_SHA256);openssl_verify返回 1 表示验签通过0 表示失败-1 表示出错。验签通过后再解密resource拿到订单状态。解密后的 payload 是 JSON 字符串里面包含out_trade_no、trade_state、transaction_id还有amount.payer_total等字段。处理完业务逻辑后必须返回微信规定的固定响应体{code: SUCCESS, message: 成功}这句话要写在业务处理成功之后。如果业务处理失败不要返回 SUCCESS微信会在几秒内重试。注意回调处理必须做幂等。同一笔订单可能收到多次通知处理前先查订单状态已经标记已支付的直接返回 SUCCESS 即可避免重复入账。3.4 订单查询与关闭订单查询订单接口路径GET /v3/pay/transactions/out-trade-no/{out_trade_no}?mchid1600000000注意两点。第一{out_trade_no}在路径里必须做 URL encode因为订单号里可能出现斜杠等特殊字符。第二GET 请求没有 body签名串里的 body 位置就是一个空字符串但五个字段的换行一个都不能少。查询结果里trade_state常见取值有 SUCCESS、REFUND、NOTPAY、CLOSED、REVOKED、USERPAYING。我们主流程里一般只关心 SUCCESS其他状态按各自情况处理。关闭订单接口路径POST /v3/pay/transactions/out-trade-no/{out_trade_no}/close关闭订单需要传 body内容是一个 JSON 对象至少包含mchid。这个接口只对“待支付”状态的订单有效已经支付的订单会返回错误码。3.5 发起退款与退款查询退款接口是独立的另一套路径POST /v3/refund/domestic/refunds参数示例$params [ out_trade_no $orderNo, out_refund_no $refundNo, notify_url $config[refund_notify_url], amount [ refund $refundAmount, // 退款金额单位分 total $orderAmount, // 订单总金额单位分 currency CNY ] ];退款有几点容易踩退款金额不能大于订单金额部分退款可以多次发起out_refund_no和订单号一样同一商户号下必须唯一重复提交会报错退款也有异步回调通知处理逻辑和支付回调完全一致只是解密后的内容字段不同解密方法直接复用即可退款查询GET /v3/refund/domestic/refunds/{out_refund_no}?mchid1600000000退款状态字段是status常见值有 SUCCESS、CLOSED、PROCESSING、ABNORMAL。明确退款成功SUCCESS或者退款关闭CLOSED才能算处理结束PROCESSING 时不要去改订单状态等待回调通知会更稳妥。4. 常见问题与排查技巧实录4.1 证书序列号报错或证书校验失败现象发起下单请求时接口返回“商户证书序列号错误”或“证书校验失败”。排查思路确认你用的是商户 API 证书的 serial_no而不是平台证书的 serial_no检查 serial_no 是否是从 apiclient_cert.pem 中读出的原样值不含空格、冒号确认商户 API 私钥和证书是同一对密钥重新生成证书后私钥也要同步更换我在代码里留了一个自检命令执行后会把本地的商户证书序列号和配置里的 serial_no 打印出来做对比一眼就能看出问题。4.2 回调验签失败怎么排查这是个高频问题大概率不是加密算法的问题而是这些细节可能原因验证方法解决办法用的是商户证书公钥验签看代码用的哪个文件必须用 platform_cert.pem 平台证书原始 body 被二次处理打印验签前拼接的字符串用 php://input 原样取 body平台证书过期看报错时间是否在证书有效期后调用证书更新接口刷新平台证书请求头字段读取错PHP 里$_SERVER的 key 大小写读取HTTP_WECHATPAY_SIGNATURE等还有一个细节容易漏微信支付平台的请求头名称在 PHP 的$_SERVER里会变成HTTP_WECHATPAY_NONCE注意大小写和前缀。如果你用的是框架自带的请求对象有的框架会把 header 名自动转换成首字母大写需要特殊处理。4.3 AES 解密失败多半是这些低级错误回调解密的时候报openssl_decrypt失败常见原因密文 base64 解码顺序不对先 base64 解码再截 tag不是先截 tag 再解码tag 截取的字节数是 16不是 24 或者 32GCM 模式 tag 固定 16 字节nonce 用错解密用的 nonce 是resource.nonce不是请求头里的Wechatpay-NonceAPIv3 密钥写错这个密钥是商户平台设置的那串 32 位字符不是证书私钥的密码调试时很多人拿了一个 base64 字符串直接在在线工具里解密结果自然失败。本地验证建议把 decrypt 步骤单测一下用一个已知的明文和密钥跑一遍确保工具类没问题再排查别的。4.4 签名串构造的边界条件签名一直失败除了证书问题就是签名串拼接的问题。整理一份边界条件清单GET 请求的 body 为空串但五个部分的换行符不能少URL 路径包含 query string 时query string 必须全部拼进去请求体必须和实际发送的 body 字节保持一致json_encode时参数顺序变了也算不一致对中文字符建议使用JSON_UNESCAPED_UNICODE编码保证 body 内容可读签名计算统一我之前遇到过一件事某个框架在中间件里给body自动加了空格结果签名一直对不上排查了很久才定位到是框架的锅。为此我在日志里把“实际发送的 body”和“用于签名的 body”都打了 log一旦签名异常两边一对比就能定位。4.5 时间戳与随机串的细节时间戳必须用秒级的不能填毫秒。PHP 的time()返回的就是秒级时间戳天然合适。注意签名头里的timestamp必须和签名串里的timestamp完全一致好多时候错误就是这两个地方的值不一样。微信要求时间戳与服务器时间误差在 5 分钟内所以服务器时间不准也会导致请求失败。如果环境里的系统时间经常漂移建议利用 NTP 同步并从代码层面记录当前时间和微信服务器时间的差值。随机串我的实现是$nonceStr bin2hex(random_bytes(16)) . (string) microtime(true) * 1000;加微秒时间戳是为了避免并发场景下随机串冲撞。并发下重复随机串可能导致签名重放微信那边会拒绝。5. 关于代码封装的扩展思路这套代码目前已经搭好 v3 的完整底座如果后续项目要接商家转账、分账、代金券这类功能签名和加解密体系是可以直接复用的只需要新增业务接口方法就行。我个人的一套习惯是凡是所有对外请求方法都走WechatPayV3Service::request()这一个入口这样日志、异常处理、告警都能统一挂在入口处。线上运维时日志里搜索request_url和response_code就能把整个支付链路的调用情况串起来。最后分享一个联调经验。微信官方有一个 API v3 签名调试工具在本地联调早期可以利用它生成一组“标准签名”样本然后拿你代码生成的签名去对照快速定位是签名串格式的问题还是密钥加载的问题。这个操作能省下大量时间。整套代码里最难的部分其实就是签名和加解密体系理解了 v3 的设计思路后续再看微信支付官方文档的各种业务接口都会变得非常顺畅。
RELATED

相关推荐

VideoLLM复现指南:token压缩、音频接入与时间感知

VideoLLM复现指南:token压缩、音频接入与时间感知

VideoLLM方向的新论文,读到现在是第三篇笔记了。前两篇我主要梳理了多模态主干的接法、指令数据的构造和基础评估这些地基问题,这一篇想换一条主线:不按模型结构排,也不照排行榜念,而是挑几个自己复现时真正觉得有得聊…

📅 2026/10/20 14:42:46
ARMCC 5.06安装与AC5切换实战:解决老嵌入式工程编译难题

ARMCC 5.06安装与AC5切换实战:解决老嵌入式工程编译难题

简介:ARM Compiler 5.06(960)是ARM推出的官方C编译器,专为基于ARM架构的嵌入式开发设计,与Keil MDK工具链无缝集成,面向嵌入式硬件工程师和物联网开发者,能够将C语言工程高效编译为可烧录的机器…

📅 2026/10/20 14:42:46
基于DQN的无人机三维航迹规划:PyTorch实现与工程实践

基于DQN的无人机三维航迹规划:PyTorch实现与工程实践

最近一直在折腾无人机航迹规划,试过A*、RRT这些传统方法,也试过PID跟踪,但一到三维城市环境里就各种碰壁:地图一复杂,搜索效率掉得厉害;环境一变,路径就得从头算。后来把强化学习搬进来&#xf…

📅 2026/10/20 14:42:46
MORE NEWS

更多资讯

📰

diagram-design:系统性思维的可视化工程实践

1. 为什么“diagram-design”不是画图,而是一场系统性思维重构“diagram-design”这个词组乍看像一个工具操作指令——打开绘图软件、拖拽几个矩形、连几根箭头、加点文字,导出PNG完事。我见过太多团队把这当成交付物:需求评审会上&#xff0…

📰

Titanic实战:GBDT与LLM对比,表格数据建模的取舍

1. 先把话说透:GBDT和LLM在表格数据上的真实处境 聊到表格数据建模,业内一直有个心照不宣的结论:凡是结构化数据,GBDT系模型(LightGBM、XGBoost、CatBoost)依然是性价比最高、最稳的默认选择。我做了几年机…

📰

无法生成博客:输入信息严重缺失

我无法基于当前输入生成符合要求的博文。 原因如下: 输入中仅提供了项目标题 "rea" ,未提供任何有效上下文: 项目正文 为空( 项目正文: [通常比较零散、不完整的原始描述,可是任意领域内容] &#x…

📰

Git Auto Merge 的真相:它不解决语义冲突,只处理文本行合并

1. 为什么“自动合并”从来不是自动的——一个被低估的协作认知断层Git 的 Auto Merge 功能,名字里带“自动”,但实际工作中,我见过太多人把它当成“点一下就完事”的魔法按钮。某次参与某高校实验室的跨平台系统协作开发时,三位同…

📰

以太网链路聚合实战指南:LACP配置、故障排查与带宽优化

1. 以太网链路聚合:不是“堆线”,而是让多条物理链路真正变成一条“超级管道”你有没有遇到过这种情况:核心交换机到服务器机柜之间只有一根千兆网线,但实际业务流量已经稳定在900Mbps以上,偶尔突发流量直接打满带宽&a…

📰

AI生图新突破:Ideogram Edit V2如何解决局部编辑与文字渲染难题

1. 为什么局部编辑和文字渲染,成了AI生图的最后两座山头这两年AI生图工具层出不穷,日常出图、风格模仿、光影控制这些能力已经被卷得很成熟了。但你要是把时间拉长一点看,会发现有两件事始终在做工粗糙的名单上:一是对已生成图片做…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬