ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

PHP接入微信商家转账到零钱实战:从签名到回调全解析

PHP接入微信商家转账到零钱实战:从签名到回调全解析 去年有个电商客户找到我说运营要搞“邀请好友得现金”的活动用户邀请成功后在零钱里直接到账最好还能自动结算分销佣金。当时第一反应是发微信红包但测试了一圈发现红包金额有限制、频次也卡得死根本撑不住批量场景。后来换成了微信支付官方的“商家转账到零钱”能力也就是老开发者熟悉的“企业付款到零钱”问题一下子解决了。这篇文章就围绕 PHP 接入最新版微信商家转账的完整过程来写从接口选型、参数准备、核心代码到排查实录尽量把能踩的坑都提前给你踩一遍。适合正在做 PHP 支付系统、或者准备上分销返佣/自动打款功能的后端开发者阅读零基础入门也没问题照着思路走完一遍就知道整个链路是怎么转起来的。1. 微信商家转账是什么能解决哪些实际问题1.1 功能定位与典型使用场景微信商家转账在微信支付官方体系里的全称叫“商家转账到零钱”它是微信支付提供给商户的一种主动付款能力商户通过 API 接口直接把资金从商户余额打到用户微信零钱里。用户端无感收款零钱里到账后可以直接消费、发红包或提现。这类能力最常见的落地场景有几类分销返佣用户 A 邀请用户 B 下单系统自动给 A 结算佣金。营销补贴分享助力、签到打卡、现金红包式的活动奖励。退款与赔付订单退款、售后补偿、运费险赔付。报销打款企业给员工的交通补贴、话费补贴、备用金报销。薪酬发放部分合规场景下的小额劳动报酬结算。说白了它就是帮你把“财务手动转账用户”这个动作变成“系统自动调用接口转账”省人力、无遗漏、每一笔都有记录和回调可对账。1.2 商家转账和微信红包、API退款的本质区别很多第一次接触的人会把这几类能力搞混这里用一张表格直接对比看完你就知道为什么有些场景必须用商家转账。对比维度商家转账到零钱微信现金红包微信支付退款资金方向商户余额 → 用户零钱商户余额 → 用户零钱商户收款原路退回触发方式企业主动发起企业主动发起只能针对已支付订单单笔限额单笔最低 1 元上限按商户号配置通常有 1 元至几百元不等的限制最多等于原订单金额用户操作部分场景需用户确认收款用户需要手动领取自动原路退回适用场景佣金、报销、补贴等自定义业务促销红包、节日福利售后退款、订单取消接口协议微信支付 API v3现金红包 API微信支付 v2/v3 均可从表里能看出来商家转账最核心的优势是“主动、可控、场景自定义”。它不像红包那样有强营销属性也不像退款那样依赖原订单而是一套通用的“企业到个人的资金通道”。1.3 申请开通需要满足的前置条件商家转账不是开了商户号就能立刻用的必须满足几个基础条件缺一个都会卡在申请环节已认证的微信支付商户号且商户号状态正常。一个已通过微信认证的服务号、小程序或 App 的 AppID并且与商户号完成关联绑定。有用户的 openid用户在该 AppID 下的唯一标识这是转账到账的必要信息之一。商户号需要申请开通“商家转账到零钱”产品权限并按实际业务场景提交对应的转账场景 ID。服务器需要准备 HTTPS 公网回调地址用于接收转账结果通知。这里要特别提醒如果你的业务涉及“给用户发佣金”申请场景时可以重点关注佣金、营销奖励等类目类目不对可能直接导致转账被拦截或者申请被驳回。2. PHP 开发者需要先搞懂的新旧接口差异2.1 从 v2 企业付款到 v3 商家转账到底“最新”在哪老一批做过微信支付开发的肯定知道“企业付款到零钱”这个接口。它的协议走的是微信支付 v2XML 格式数据、MD5 或 HMAC-SHA256 签名、用 APIv2 密钥做对称签名开发时最痛苦的就是签名字符串拼接顺序一个字段顺序错了整单都发不出去。新版“商家转账到零钱”全面切到了微信支付 API v3 协议协议层面变化非常大数据格式从 XML 变成了 JSON。请求签名从 MD5/HMAC-SHA256 变成了 SHA256withRSA 非对称签名。新增了平台证书机制所有请求都要携带商户 API 证书序列号。回调通知里的业务数据用 AES-256-GCM 算法加密必须用 APIv3 密钥解密后才能看到明细。敏感信息姓名、身份证号、银行卡号等字段传输时需要用微信支付公钥加密。对 PHP 开发者来说v3 这套协议初看门槛比 v2 高因为涉及证书和加解密但只要理解了它的设计逻辑代码结构反而比 v2 清晰官方 SDK 也已经相当完善。2.2 v3 接口的几个关键设计为什么要改先说签名方式。v2 的 MD5 签名本质上是对称密钥签名只要 API 密钥泄露攻击者可以伪造任意金额的发起请求。v3 改成 SHA256withRSA 后私钥保存在商户服务器端对外只暴露公钥和证书序列号即使证书被下载也无法反推私钥安全等级高很多。再说数据格式。XML 解析在 PHP 里本身不麻烦麻烦的是字段粒度和嵌套结构不够清晰复杂场景下很容易漏字段。JSON 天然更适合表达批量转账这种“批次 明细”的层级结构PHP 里直接数组转 json_encode 就能构造返回结构也可以用 json_decode 一步到位。最后说回调加密。v2 的回调通知基本是明文传输只要拿到回调地址就能看到全部业务数据。v3 对 resource 节点里的 ciphertext 做 AES-256-GCM 加密即使回调地址被中间人截获没有 APIv3 密钥也解不出转账明细这层设计明显在加强端到端的隐私保护。2.3 新旧接口选型建议如果你的项目是 2024 年以后新立项的直接上 v3“商家转账到零钱”就好别走老路做 v2 企业付款因为微信官方已经在逐步引导老商户切换新接口新接口的功能兼容性更好后续对账、退款、批次查询接口也更全。如果是老项目正在跑 v2 企业付款也不要急着推翻重写业务稳定优先可以先用小额灰度测试新接口验证签名、回调、对账逻辑都跑通了再慢慢迁移。核心字段和业务模型其实八九不离十迁移成本主要是协议层改造。3. 开发前的准备这些参数和证书千万别搞混3.1 开发前要准备的材料清单每次接微信支付都有人栽在参数准备上这里把商家转账会用到的东西一次性列清楚。参数/材料获取位置用途商户号 mchid微信支付商户平台商户唯一标识AppID微信公众号/小程序后台用户 openid 的归属标识商户 API 证书apiclient_cert.pem商户平台 → API 安全用来生成请求签名商户 API 私钥apiclient_key.pem商户平台 → API 安全私钥签名必须妥善保管商户证书序列号商户平台证书详情放在 Authorization 头里APIv3 密钥商户平台自助设置32 字节字符串解密回调通知微信支付公钥 ID商户平台 → API 安全敏感信息加密回调通知地址开发者自建 HTTPS 接口接收转账结果通知这里要重点说明“证书序列号”这个概念。很多人第一次看到 Authorization 请求头里要传 serial_no会误以为要传平台证书序列号实际传的是你商户自己 API 证书的序列号。序列号可以在商户平台下载证书的详情里看到也可以在 PHP 里通过 openssl_x509_parse() 函数解析证书内容自动读取。3.2 商户 API 证书和平台证书谁管签名谁管验签这两个证书容易混我习惯用一句话区分商户 API 证书和私钥是用来“证明你是你”的对外请求时用私钥签名微信用你的公钥验签平台证书是用来“验证微信回应是真的”的微信用平台证书私钥签名每个响应和回调你这边用平台证书公钥验签。你的服务器上需要保存两个 pem 文件apiclient_cert.pem 和 apiclient_key.pem。前者是商户证书公钥和商户号信息的综合体后者是私钥。这两个文件默认是放在非公开目录下的生产环境一定要禁止外部访问千万别把它放到 public 目录里。平台证书或微信支付公钥则是在验证回调通知和加密敏感字段时要用的。现在官方主推“微信支付公钥”方案不再强制要求下载平台证书用公钥 ID 解密回调同样可行。我自己的实践是优先用微信支付公钥方案省去证书自动更新的麻烦。3.3 APIv3 密钥到底用来做什么APIv3 密钥是在商户平台手动设置的一串 32 字节字符串它不参与请求签名但参与三件事回调通知里 resource.ciphertext 的解密。部分敏感字段的解密。下载交易账单时的解密。这里有个非常典型的操作误区有人把 APIv3 密钥当成签名密钥用导致所有请求都报签名错误。记住v3 协议的请求签名用的只能是私钥做 SHA256withRSAAPIv3 密钥是给 AES 解密用的。3.4 PHP 环境基础要求商家转账 v3 接口对 PHP 版本没有特别苛刻的要求但建议 PHP 7.4 及以上主要依赖以下扩展openssl非对称签名、敏感字段加密、回调解密全靠它。curl发送 HTTPS 请求。json请求体和响应体的编解码。PHP 的 openssl 扩展默认会开启如果用的是宝塔或者 Docker 镜像可以先执行 php -m 查看是否包含 openssl没有的话装一下就行。Windows 环境下做本地联调时记得确认 php.ini 里 extensionopenssl 前的分号已被移除。4. PHP 调用商家转账到零钱核心代码实操4.1 第一步构造鉴权头理解这套签名规则v3 接口的鉴权原理可以理解成你给微信支付写信信封上需要盖一个“只有你才有的私章”。这个私章就是商户私钥对请求信息的签名。需要签名的原文是把以下五个信息用换行符拼接起来HTTP请求方法\n 请求路径\n 请求时间戳\n 请求随机串\n 请求报文主体\n注意请求报文主体必须是你实际发送的 body 原字符串不能是格式化后的 JSON也不能是去掉空格后的 JSON。PHP 里如果你用 json_encode($data) 构造那签名时直接用同一个字符串不要在签名后又做 var_dump 或者重新编码否则签名必然失败。下面是一个可以直接复用的 PHP 签名方法?php /** * 生成微信支付v3请求所需的签名 */ function buildWechatpaySignature( string $method, string $urlPath, string $timestamp, string $nonceStr, string $body, string $merchantPrivateKey ): string { $message $method . \n . $urlPath . \n . $timestamp . \n . $nonceStr . \n . $body . \n; $privateKey openssl_pkey_get_private($merchantPrivateKey); if ($privateKey false) { throw new RuntimeException(商户私钥格式错误); } $signature ; $isOk openssl_sign($message, $signature, $privateKey, OPENSSL_ALGO_SHA256); if (!$isOk) { throw new RuntimeException(签名失败); } return base64_encode($signature); }然后封装 Authorization 头?php function buildAuthorizationHeader( string $merchantId, string $serialNo, string $nonceStr, string $timestamp, string $signature ): string { return sprintf( WECHATPAY2-SHA256-RSA2048 mchid%s,nonce_str%s,timestamp%s,serial_no%s,signature%s, $merchantId, $nonceStr, $timestamp, $serialNo, $signature ); }这里的 random 随机串不能重复使用建议每次请求用 random_bytes 或 uniqid 重新生成。时间戳也需要使用当前 Unix 时间戳的字符串形式微信服务器会校验时间偏差超过 5 分钟就会拒绝请求所以服务器时间一定要用 NTP 同步。4.2 第二步构造请求体并处理敏感信息加密商家转账到零钱的 v3 接口路径是POST https://api.mch.weixin.qq.com/v3/transfer/batches这是一个批量转账接口一次最多支持 2000 笔明细请求体结构如下?php $transferData [ appid wx1234567890abcdef, mchid 190000123456, out_batch_no BATCH20250412001, batch_name 2025年4月佣金结算, batch_remark 邀请好友活动奖励, total_amount 5000, total_num 2, transfer_detail_list [ [ out_detail_no DETAIL20250412001, transfer_amount 3000, transfer_remark 邀请奖励, openid oUpF8uMuAJO_M2pxb1Q9zNjWeS6o, ], [ out_detail_no DETAIL20250412002, transfer_amount 2000, transfer_remark 邀请奖励, openid oUpF8uMuAJ-2ZgRkREgTnT3Gs8O0, ], ], transfer_scene_id 1000, transfer_purpose 佣金, ];这里有几个容易写错的关键点out_batch_no 是商户侧生成的批次单号必须全局唯一相当于幂等键同一批次直接重试会返回错误而不是重复转账。total_amount 单位是分而且是整数型不是浮点数不能传 30.00 这种带小数的字符串。total_num 必须和 transfer_detail_list 的数量一致。transfer_scene_id 是你开通产品权限时申请的场景 ID如佣金、营销奖励等不同商户号开放的场景可能不同。transfer_purpose 是转账用途可选参数但推荐填上方便后续财务对账。如果转账时需要通过“姓名 身份证号 openid”完成实名匹配那 user_name 和 user_id_card 这两个字段需要先加密。加密方式是使用微信支付公钥做 RSAES-OAEP 加密然后把 Base64 后的密文填入字段。PHP 里加密敏感信息的典型实现是?php /** * 加密敏感信息 * 微信支付公钥从商户平台获取字符串内容类似 * -----BEGIN PUBLIC KEY----- * ... * -----END PUBLIC KEY----- */ function encryptSensitiveField(string $plainText, string $wechatPublicKey): string { $encrypted ; $isOk openssl_public_encrypt( $plainText, $encrypted, $wechatPublicKey, OPENSSL_PKCS1_OAEP_PADDING ); if (!$isOk) { throw new RuntimeException(敏感信息加密失败); } return base64_encode($encrypted); }加密后的字段是一个 256 字节左右的 Base64 密文直接塞进 user_name 和 user_id_card 字段即可。注意如果当前转账方式只需要 openid不传用户实名信息也可以正常发起只是微信会要求用户在转账通知里确认收款。4.3 第三步发送转账请求并处理返回值请求体构造完成后下一步就是拼接 Authorization 头并发送。为了减少不必要的依赖我习惯用 PHP 的 curl 直接发送不引入 Guzzle代码量最少且可控性更强。?php function sendTransferRequest(array $transferData, array $config): array { $urlPath /v3/transfer/batches; $timestamp (string) time(); $nonceStr bin2hex(random_bytes(16)); $body json_encode($transferData, JSON_UNESCAPED_UNICODE); $signature buildWechatpaySignature( POST, $urlPath, $timestamp, $nonceStr, $body, $config[merchant_private_key] ); $authorization buildAuthorizationHeader( $config[mchid], $config[serial_no], $nonceStr, $timestamp, $signature ); $ch curl_init(https://api.mch.weixin.qq.com . $urlPath); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER true, CURLOPT_POST true, CURLOPT_POSTFIELDS $body, CURLOPT_HTTPHEADER [ Content-Type: application/json, Accept: application/json, Authorization: . $authorization, User-Agent: . $config[appid] . /PHP-SDK/1.0, ], CURLOPT_SSL_VERIFYPEER true, CURLOPT_TIMEOUT 30, ]); $response curl_exec($ch); if (curl_errno($ch)) { curl_close($ch); throw new RuntimeException(请求微信支付失败: . curl_error($ch)); } curl_close($ch); return json_decode($response, true); }正确请求后接口会返回下面这样的结构{ out_batch_no: BATCH20250412001, batch_id: 10300000711009999911820250412001, create_time: 2025-04-12T10:30:0008:00, state: ACCEPTED, transfer_batch: { appid: wx1234567890abcdef, mchid: 190000123456, out_batch_no: BATCH20250412001, batch_id: 10300000711009999911820250412001, state: ACCEPTED } }这里的 state 返回 ACCEPTED 只代表微信支付已经接收你的转账请求并不代表钱已经到账。真正决定到账结果的是异步回调通知所以业务侧千万不要以接口返回结果作为最终入账依据必须等回调结果更新订单状态。4.4 第四步接收回调通知并解密回调通知是微信服务器主动 POST 到你配置的通知 URL 上的通知包结构如下{ id: EV-20250412103000001, create_time: 2025-04-12T10:30:0208:00, event_type: TRANSFER.BATCH.TRANSFER, resource_type: encrypt-resource, resource: { ciphertext: base64加密内容, associated_data: 批次, nonce: 随机串 } }resource.ciphertext 里的内容用 APIv3 密钥做 AES-256-GCM 解密PHP 实现如下?php function decryptCallbackResource(array $resource, string $apiV3Key): array { $ciphertext base64_decode($resource[ciphertext]); $nonce $resource[nonce]; $associatedData $resource[associated_data] ?? ; $decrypted openssl_decrypt( $ciphertext, aes-256-gcm, $apiV3Key, OPENSSL_RAW_DATA, $nonce, $tag, $associatedData ); if ($decrypted false) { throw new RuntimeException(回调数据解密失败); } return json_decode($decrypted, true); }这里有一个特别容易踩的坑openssl_decrypt 的 $tag 参数在 PHP 7.1 以下是必传的在 PHP 7.1 里如果要用 AEAD 模式$tag 参数必须是从密文里取出的真实标签不能自造。微信支付回调的 ciphertext 里并不直接包含 tag 标签因此本地联调时很多人习惯传一个空字符串结果就是解密永远失败。正确做法是使用 php_openssl 的 crypt 函数先把 ciphertext 的标签提取出来或者直接用官方 SDK 的解密方法。如果你坚持自己写建议把 ciphertext 转为二进制后按“密文 tag”拆分处理不同版本和不同实现要确保处理方式一致。解密后的数据字段大致如下{ out_batch_no: BATCH20250412001, batch_id: 10300000711009999911820250412001, out_detail_no: DETAIL20250412001, detail_id: 1212231223123123123123123123, state: SUCCESS, transfer_amount: 3000, transfer_remark: 邀请奖励, openid: oUpF8uMuAJO_M2pxb1Q9zNjWeS6o, success_time: 2025-04-12T10:31:0008:00 }state 字段是这笔转账明细的最终状态常见值有 SUCCESS、FAILED、PROCESSING。拿到 SUCCESS 才能更新用户余额或佣金状态。回调处理完成后必须返回 HTTP 200 和一个空 body 给微信服务器否则微信会按一定间隔重复通知。5. 常见问题与排查技巧5.1 签名错误、验签失败类问题请求时报签名错误第一反应不要去看代码先检查以下四类情况请求体 body 有没有发生格式化变化。很多框架会在发送前对 json_encode 的结果做一次递归 ksort这就会导致签名原文与发送 body 不一致。证书序列号是不是填成了平台证书序列号。Authorization 头里的 serial_no 必须填商户 API 证书序列号。时间戳和服务器时间偏差过大。本地开发环境如果没有同步时间签名的 timestamp 比微信服务器晚几分钟就会直接失败。随机串 nonce_str 是否被重复使用。每次请求必须重新生成。这类问题排查最快的方式是把签名字符串按规则拼接出来后用微信官方提供的在线签名验证工具核对一遍能很快定位是拼接问题还是私钥问题。5.2 金额和批次参数导致的业务报错金额单位不统一是高频问题。数据库如果存的是元接口要求的是分一旦忘记乘 100就会出现“金额不匹配”或“低于最低限额”的错误。最低限额一般是 1 元即 total_amount 和明细金额都不能小于 100。total_num 与明细数量不一致、明细里出现重复的 out_detail_no、批次号超过 32 字符限制这些也会被微信接口直接拒绝。我的建议是在发起请求前做一次数据校验把金额、数量、单号长度全部检查一遍再发出去减少无意义的 API 调用。5.3 回调通知一直收不到或者解密失败回调地址必须是公网可访问的 HTTPS 地址且是 POST 请求。部分开发者在 nginx 层做了重定向把 HTTP 转 HTTPS这会导致微信请求路径变化而回调失败。需要确认回调路径最终到达 PHP 脚本并且响应 Content-Type 是 application/json。解密失败除了前面提到的 tag 参数问题还有一个隐蔽原因APIv3 密钥设置时包含了大写字母、小写字母、数字和特殊符号但复制到代码里时不小心多了一个空格或换行。密钥必须是 32 字节的原始字符串任何不可见字符都会导致 AES 解密失败。5.4 PHP 开发中的几个专项坑curl 没有设置 CURLOPT_SSL_VERIFYPEER 为 true 时生产环境有安全风险但本地联调如果证书链不完整又会报 SSL 错误。建议开发环境临时设成 false生产环境必须 true。json_encode 默认会把中文转成 \uXXXX有些签名库会把转义后的字符串再转回来导致签名原文不一致。建议统一使用 JSON_UNESCAPED_UNICODE。PHP 的整数溢出问题。系统是 32 位 PHP 时金额如果超过一定的数值上限会出现意外结果生产环境强烈建议 64 位 PHP。回调接口要加入验签逻辑不能只解密就更新订单状态。微信回调通知的 headers 里有 Wechatpay-Signature 等参数需要用平台公钥验签后再解密否则容易被伪造通知打爆余额逻辑。总结与个人实操心得按照这套流程完整跑通后我的最大体会是微信商家转账到零钱本身并不复杂复杂的是对证书体系和回调机制的理解。个人建议初期接入时先跑通一笔 1 元转账的完整链路从发起、回调、解密到对账全部验证通过后再放开批量场景。批次号 out_batch_no 一定要做好幂等设计转账结果只信回调不要信同步返回的 ACCEPTED。代码里尽可能把签名、加密、请求、回调解密封装成独立的 PHP 类方便多业务复用也为后续排查问题留出清晰边界。最后提醒一句妥善保管好商户私钥和 APIv3 密钥这两样一旦泄露基本等于把资金通道交到了别人手里。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进