
简介基于ThinkPHP开发的微信支付商家转账到零钱Demo面向需要快速集成微信支付转账能力的PHP开发者与中小商家技术团队适用于退款、返佣、报销、活动奖励等资金直达场景帮助实现资金从商户账户安全转入用户零钱。代码遵循简单实用原则将复杂支付流程封装为可复用模块开发者只需关注核心业务参数即可完成调用大幅缩短联调周期。压缩包共825个文件以639个PHP源码文件为主辅以JSON配置、SQL脚本、Markdown文档、环境配置样例等构成完整ThinkPHP项目结构包体仅1.61MB轻量易部署。目前已有1474人学习下载。通过学习这份代码可以掌握商家转账到零钱的完整链路接入前权限准备、支付参数设置、转账请求构建、API提交、结果回调与验签、失败处理同时了解ThinkPHP框架下支付模块的目录组织方式以及签名校验、安全防护、合规使用等实战细节。源码自带基础测试与规范配置适合初学者学习支付对接流程也适合有经验者作为二次开发和上线参考。1. 商家转账到零钱是什么先分清这个接口在解决什么问题“商家转账到零钱”是微信支付商户平台里一类被低估的接口。它做的事情是商户后台调用一次接口把商户余额里的钱直接打进用户微信零钱不需要用户扫码、不需要用户确认到账后用户微信里会收到零钱入账通知。我最早接它是在一个分销返佣项目里当时要给几百个推广员发奖励逐个打款不现实财务手动转又容易漏换了这个接口之后一个批次请求把整批明细发过去剩下的事就是等回调、对账。这个功能适合三类场景平台给推广员发佣金、企业给员工发报销款或补贴、电商退款之外给用户发现金补偿。标题里写了 thinkphp我按新版“商家转账”接口来搭这套 Demo不是旧版的“企业付款”。代码可以直接抄权限开通条件和踩坑点也会一并讲清楚新手照着做能跑通熟手看完能少填几个坑。2. 动手前的三件事权限开通、APIv3密钥、证书与请求签名微信支付的接口看着是发一个 HTTP 请求实际上真正拦住你的往往不是业务逻辑而是权限、密钥、签名这三件事没理清楚。老话说“证书都对了就能通”但很多人在第一步就接成了旧版接口或者证书序列号读错导致签名字段无效。这一章先把地基打牢后面写代码才不玄学。2.1 先选对接口新版商家转账和旧版企业付款的差别以前这个场景用的接口叫“企业付款到零钱”走的是 APIv2 体系接口文档在“企业付款”目录下。现在官方主推的是“商家转账”走 APIv3接口路径是/v3/transfer/batches。两个接口表面功能一样底层完全两套千万别混着配。新旧接口的核心差异在四个地方。第一是密钥体系APIv2 用的是 MD5/HMAC-SHA256 加 API 证书APIv3 用的是商户证书私钥签名、平台证书验签签名串格式完全不同。第二是数据结构新版把转账拆成“批次 明细”两级一次请求可以带最多几百笔明细旧版一次只能发一笔。第三是新版要求 appid、mchid、openid 三者主体一致旧版对主体绑定没这么严格。第四是回调报文新版通知里的业务数据用 AES-256-GCM 加密要先解密才能读。选新版的原因很实际新商户号在旧版上基本开不通权限官方产品页也把旧版标记为存量商户继续使用新接入的都走商家转账。所以这套 Demo 只讲新版代码、参数、坑都按新版来。2.2 准备 APIv3 密钥和证书四把钥匙的分工很多开发者第一次配置时会被一堆“证书”“密钥”“序列号”绕晕。我习惯把它想成四把钥匙分工不一样弄混了签名必挂。配置项用途获取位置常见误区商户号 mchid请求身份标识商户平台首页填成 AppIDAppID标识公众号/小程序公众平台必须与商户号已绑定商户证书序列号 serial_no告诉微信用哪把公钥验你的签名商户平台-API 安全当成证书文件 MD5商户证书私钥 apiclient_key.pem给请求签名API 证书下载时生成服务器路径配错APIv3 密钥解密回调报文商户平台-API 安全手动设置32 字节多空格或少字符平台证书公钥验签微信回调/v3/certificates下载有有效期需定时刷新其中最容易翻车的是“商户证书序列号”。它不是证书文件本身的 MD5 或指纹而是商户平台 API 安全页面里展示的一串 16 进制序列号签名头里填的就是这个值。私钥文件下载后往往只有一个压缩包解压出来的apiclient_key.pem要放到服务器上路径用绝对路径别用相对路径否则 CLI 定时任务和 Web 请求可能读到不同的文件。2.3 用 ThinkPHP 落一个签名工具类请求签名是全链路第一步APIv3 的签名规则是把 HTTP 方法、URL 路径含问号后的查询参数、时间戳、随机串、请求体GET 为空用换行符拼起来再用商户私钥做 RSA-SHA256 签名。先写配置文件再写签名类。?php // config/wechat_pay.php // 所有敏感配置走 env不要硬编码进代码仓库 return [ api_v3_key env(WECHAT_PAY_V3_KEY, ), mchid env(WECHAT_PAY_MCHID, ), appid env(WECHAT_PAY_APPID, ), serial_no env(WECHAT_PAY_SERIAL_NO, ), private_key_path env(WECHAT_PAY_PRIVATE_KEY_PATH, ), platform_pub_path env(WECHAT_PAY_PLATFORM_PUB_PATH, ), notify_url env(WECHAT_PAY_NOTIFY_URL, ), ];?php declare(strict_types1); namespace app\common\util; use think\helper\Str; /** * 微信支付 APIv3 请求签名 * 签名串组装顺序固定最后一个字段后也有换行符 */ class WechatPayV3Sign { public static function authHeader(string $method, string $urlPath, string $body ): string { $config config(wechat_pay); // 时间戳和随机串每次请求都要重新生成不能缓存复用 $timestamp time(); $nonce Str::random(16); // 待签名串HTTP方法\nURL路径\n时间戳\n随机串\n请求体\n $message strtoupper($method) . \n . $urlPath . \n . $timestamp . \n . $nonce . \n . $body . \n; $privateKey file_get_contents($config[private_key_path]); if ($privateKey false) { throw new \RuntimeException(商户证书私钥读取失败检查 private_key_path 配置); } // 使用 sha256WithRSAEncryption 算法签名 openssl_sign($message, $signature, $privateKey, sha256WithRSAEncryption); // 拼成 Authorization 头字段名和值都不能写错 return sprintf( WECHATPAY2-SHA256-RSA2048 mchid%s,nonce_str%s,signature%s,timestamp%d,serial_no%s, $config[mchid], $nonce, base64_encode($signature), $timestamp, $config[serial_no] ); } }这串代码的核心是两个点。第一签名串里 URL 路径带不带查询参数必须和实际请求一致比如查询批次时 URL 带?need_query_detail1签名用的字符串也必须带上它。第二Authorization头里的 mchid 是商户号不是 AppID这两者在配置文件里分开写避免在业务代码里混用。签名类写完可以先打印一行出来人工核对再发第一个正式请求。之后所有发往微信支付的请求都复用这个类不会出现各接口各写一套签名逻辑的问题。3. ThinkPHP 配置与转账调用一次能跑通的商家转账请求签名工具就绪后进入业务主体发起商家转账。整套流程是“落库 → 组装报文 → 请求微信 → 按响应更新状态”。我习惯把批次和明细分开设计因为微信把状态拆成两级批次可以部分成功本地只有一张表不好表达这种粒度。3.1 数据库表设计批次、明细分开落库转账是资金操作数据表必须能完整回溯。批次表存总金额、总笔数、整体状态明细表存每个用户、每笔金额、每笔状态。状态字段我用pending / success / partial / failed四档和微信的批次状态对应。CREATE TABLE transfer_batch ( id int unsigned NOT NULL AUTO_INCREMENT, out_batch_no varchar(64) NOT NULL COMMENT 商户批次单号唯一, batch_name varchar(64) NOT NULL COMMENT 批次名称, batch_remark varchar(128) NOT NULL DEFAULT COMMENT 批次备注, total_amount int NOT NULL COMMENT 总金额单位分, total_num int NOT NULL COMMENT 总笔数, status varchar(16) NOT NULL DEFAULT pending COMMENT pending/success/partial/failed, create_time int NOT NULL, update_time int NOT NULL DEFAULT 0, PRIMARY KEY (id), UNIQUE KEY uniq_out_batch_no (out_batch_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT商家转账批次表; CREATE TABLE transfer_detail ( id int unsigned NOT NULL AUTO_INCREMENT, out_batch_no varchar(64) NOT NULL COMMENT 关联批次单号, out_detail_no varchar(64) NOT NULL COMMENT 商户明细单号唯一, openid varchar(64) NOT NULL COMMENT 用户openid, amount int NOT NULL COMMENT 转账金额单位分, status varchar(16) NOT NULL DEFAULT pending COMMENT pending/success/failed, fail_reason varchar(255) NOT NULL DEFAULT COMMENT 失败原因, create_time int NOT NULL, PRIMARY KEY (id), UNIQUE KEY uniq_out_detail_no (out_detail_no), KEY idx_batch_no (out_batch_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT商家转账明细表;两张表都建了唯一索引这不仅是数据库约束更是幂等屏障。同一批明细不能重复落库同一笔转账不会因为重试出现两条记录。金额字段我用int单位是分这是资金操作里最重要的设计决策后面避坑章节会专门说 float 怎么坑人。3.2 发起转账组装参数、签名、用 cURL 发请求核心服务类写在app/common/service下供控制器调用。注意请求函数我用 cURL 手写不依赖某个 HTTP 客户端扩展避免环境差异。请求体用JSON_UNESCAPED_UNICODE编码中文备注直接原样传微信那边能正常显示。?php declare(strict_types1); namespace app\common\service; use app\common\util\WechatPayV3Sign; use think\facade\Db; class TransferService { /** * 创建商家转账批次 * param array $transfers 明细列表: [[amount1.5,openidxxx,remark备注], ...] * param string $batchName 批次名称如7月佣金发放 * param string $batchRemark 批次备注 */ public function createBatch(array $transfers, string $batchName, string $batchRemark) { // 金额统一转成“分”后续计算全部用 int杜绝浮点误差 $totalAmount 0; $totalNum count($transfers); $detailList []; // 单号尽量独立Demo 用时间戳随机数生产建议接雪花或UUID $outBatchNo date(YmdHis) . _ . random_int(1000, 9999); foreach ($transfers as $i $item) { // 用 round 四舍五入避免 0.29 * 100 得到 28.9999999 $amountFen intval(round($item[amount] * 100)); $outDetailNo $outBatchNo . _ . $i; $detailItem [ out_detail_no $outDetailNo, transfer_amount $amountFen, transfer_remark $item[remark] ?? $batchRemark, openid $item[openid], ]; // 需要校验实名时才传 user_name不校验就不传 if (!empty($item[user_name])) { $detailItem[user_name] trim($item[user_name]); } $detailList[] $detailItem; Db::name(transfer_detail)-insert([ out_batch_no $outBatchNo, out_detail_no $outDetailNo, openid $item[openid], amount $amountFen, status pending, create_time time(), ]); } Db::name(transfer_batch)-insert([ out_batch_no $outBatchNo, batch_name $batchName, batch_remark $batchRemark, total_amount $totalAmount, total_num $totalNum, status pending, create_time time(), ]); $body [ appid config(wechat_pay.appid), out_batch_no $outBatchNo, batch_name $batchName, batch_remark $batchRemark, total_amount $totalAmount, total_num $totalNum, transfer_detail_list $detailList, ]; return $this-requestV3(/v3/transfer/batches, true, $body); } private function requestV3(string $urlPath, bool $isPost, array $body []) { $url https://api.mch.weixin.qq.com . $urlPath; $bodyStr $body ? json_encode($body, JSON_UNESCAPED_UNICODE) : ; $auth WechatPayV3Sign::authHeader($isPost ? POST : GET, $urlPath, $bodyStr); $ch curl_init($url); if ($isPost) { curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, $bodyStr); } curl_setopt($ch, CURLOPT_HTTPHEADER, [ Authorization: . $auth, Accept: application/json, Content-Type: application/json, User-Agent: thinkphp-transfer-demo/1.0, ]); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2); curl_setopt($ch, CURLOPT_TIMEOUT, 30); $response curl_exec($ch); if (curl_errno($ch) ! 0) { trace(curl error: . curl_error($ch), error); curl_close($ch); return [code -1, msg 网络请求失败]; } curl_close($ch); $data json_decode($response, true); if (!is_array($data)) { trace(微信支付返回非JSON: . $response, error); return [code -1, msg 微信支付响应格式异常]; } // APIv3 错误时返回 code 字段没有 code 字段视为成功 if (isset($data[code])) { trace(微信支付业务错误: . json_encode($data, JSON_UNESCAPED_UNICODE), error); return [code (int)($data[status] ?? -1), msg $data[message] ?? 业务失败]; } return $data; } }参数上有几个地方特别容易错单独说一下。total_amount必须等于transfer_detail_list里所有transfer_amount之和微信会做一致性校验多一分少一分都拒绝。total_num是明细笔数不是人数同一用户拆两笔就按两笔算。批次名称最长 32 字节备注最长 128 字节超长会被拒中文按 UTF-8 算字节数不是字符数。user_name只在你确定需要校验用户实名时才传传了就必须和对应 openid 的实名信息一致否则报“姓名校验失败”。3.3 微信返回了什么拿到结果不等于到账完成请求发出去后微信会立刻返回一个结果包含batch_id、out_batch_no、create_time。这个响应只代表“受理成功”不代表钱已经进了用户零钱最终状态要等异步通知或主动查询才知道。现实中很多开发者在这里栽跟头接口返回 200 就把本地订单标记成成功结果用户说没收到钱再查才发现批次是失败的。我建议把响应里的batch_id存进批次表它是微信侧的批次标识后面查单、对账都要用到。如果响应里带了code字段按业务失败处理把message存进日志本地批次保持pending状态等用户手动重试或定时任务重试。另外要留意微信的接口返回里status字段只有部分接口才有不要拿它当通用成功判断。4. 查询与回调把“发出去了”变成“确定到账了”一个转账功能能不能上线关键看两条链路主动查询和被动通知。主动查询适合做定时任务对账被动通知负责实时更新状态。两个都接上用户付款后 5 分钟内状态就是终态运营后台也能随时看到卡在哪。4.1 查询批次用 out_batch_no 查微信侧状态查询接口用 GET 方式路径里的out_batch_no是商户侧批次单号need_query_detail1表示同时返回明细列表。注意这个查询参数要拼进 URL 路径参与签名我见过有人只对路径签名、漏了参数结果一直报签名错误。public function queryBatch(string $outBatchNo): array { // 这个 urlPath 会被原样用于签名查询参数也必须带上 $urlPath /v3/transfer/batches/out-batch-no/ . $outBatchNo . ?need_query_detail1; return $this-requestV3($urlPath, false); }返回的数组里state字段可能的值有FINISHED、ACCEPTED、CANCELED等transfer_detail_list里每笔明细也有自己的state。本地更新逻辑可以这样写批次是FINISHED且明细全部成功批次标记success有成功有失败则标记partial如果某批次创建后长时间没有通知又查不到有效状态保持pending并报警。4.2 回调通知验签、解密、更新订单状态回调地址要配成 HTTPS 公网可访问的 URL比如https://yourdomain.com/api/wechat/transfer/notify。微信推送的是 POST 请求头部带Wechatpay-Timestamp、Wechatpay-Nonce、Wechatpay-Signature、Wechatpay-Serial四个字段body 里的resource是加密后的业务数据。处理顺序必须是先验签再解密最后更新本地状态。public function notify() { // nginx 下 getallheaders 不一定可用改用 $_SERVER 兼容 $timestamp $_SERVER[HTTP_WECHATPAY_TIMESTAMP] ?? ; $nonce $_SERVER[HTTP_WECHATPAY_NONCE] ?? ; $signature $_SERVER[HTTP_WECHATPAY_SIGNATURE] ?? ; $serial $_SERVER[HTTP_WECHATPAY_SERIAL] ?? ; $body file_get_contents(php://input); $raw json_decode($body, true); // 1. 验签微信用的签名串是 时间戳\n随机串\n请求体\n $message $timestamp . \n . $nonce . \n . $body . \n; $publicKey file_get_contents(config(wechat_pay.platform_pub_path)); $ok openssl_verify($message, base64_decode($signature), $publicKey, sha256WithRSAEncryption); if ($ok ! 1) { // 验签失败直接返回 FAIL微信会按策略重试 return json([code FAIL, message 验签失败]); } // 2. 解密 resource 里的密文 $resource $raw[resource]; $decrypted $this-decryptResource($resource); $notifyData json_decode($decrypted, true); // 3. 更新本地批次状态 $outBatchNo $notifyData[out_batch_no] ?? ; if ($outBatchNo ! ) { $state $notifyData[state] ?? ; $map [FINISHED success, ACCEPTED pending, CANCELED failed]; $localStatus $map[$state] ?? pending; Db::name(transfer_batch)-where(out_batch_no, $outBatchNo)-update([ status $localStatus, update_time time(), ]); } // 4. 返回 SUCCESS告诉微信不要再重试 return json([code SUCCESS]); } private function decryptResource(array $resource): string { $key config(wechat_pay.api_v3_key); $ciphertext base64_decode($resource[ciphertext]); $nonce $resource[nonce]; $aad $resource[associated_data]; // GCM 模式下 tag 在密文尾部微信返回的 ciphertext 最后 16 字节是 tag $tag substr($ciphertext, -16); $ciphertextWithoutTag substr($ciphertext, 0, -16); $plain openssl_decrypt( $ciphertextWithoutTag, aes-256-gcm, $key, OPENSSL_RAW_DATA, $nonce, $tag, $aad ); if ($plain false) { throw new \RuntimeException(回调报文解密失败检查 api_v3_key 是否正确); } return $plain; }这一步最隐蔽的坑是解密参数微信的 AES-256-GCM 密文里最后 16 字节是验签 tag要先切出来不能整段交给openssl_decrypt。nonce和associated_data必须用报文里原样返回的值api_v3_key是 APIv3 密钥本身不是它的哈希。验签用的公钥是平台证书的公钥不是商户证书也不是 APIv3 密钥这三者千万别搞混。如果用了 nginxgetallheaders()可能拿不到微信的自定义头改用$_SERVER[HTTP_WECHATPAY_XXX]这种形式最稳。5. 商家转账避坑清单5 个高频翻车点这套功能接多了翻车点高度集中。下面 5 条按“现象 → 原因 → 解决”写都是实际排查过的血泪经验照着核对能省半天时间。5.1 报错“商户单号已存在”幂等设计没做对现象第一次请求超时或返回异常业务方按失败处理重新发起结果微信提示商户单号已存在但本地没有这笔单的记录。原因out_batch_no和out_detail_no在微信侧有唯一约束只要请求被微信受理过单号就不能复用超时并不代表没受理。解决重试前先查单用原来的out_batch_no调查询接口看实际情况已经存在的就用新单号发起新请求。本地表加唯一索引也是防止并发重试产生两条相同单号。5.2 报错“姓名校验失败”实名参数传错场景现象部分转账明细失败错误信息提示用户姓名与实名认证不一致。原因user_name传了之后微信会把姓名和 openid 对应的实名信息做比对只要商户侧存的姓名和微信实名有任何出入比如多了个空格、用了昵称就会失败。解决不需要验实名时就不传user_name必须校验的场景入库前做trim并确认采集的就是用户真实姓名别拿昵称或花名往上填。5.3 回调一直验签失败证书过期和环境差异现象日志里每次回调都走到验签失败分支微信侧显示重试次数不断增加。原因平台证书有有效期过期后platform_pub_path指向的文件还是旧证书另一个常见原因是 nginx 下没取到微信的自定义请求头导致验签串里的 timestamp 是空字符串。解决写定时任务定期调/v3/certificates接口刷新平台证书检测到序列号变化就更新本地文件请求头统一从$_SERVER取避免 fastcgi 环境差异。5.4 金额精度问题float 算出 28.9999999现象接口报total_amount与明细金额之和不等或者某笔明细金额莫名少了 1 分。原因PHP 浮点运算不是精确十进制0.29 * 100在部分环境下结果是28.999999999999996intval直接截断成 28。解决入参金额统一转成“分”的 int 再计算代码里用intval(round($item[amount] * 100))所有比较和累加都用 int。这个坑在装机量很大的环境里更容易踩因为不同 CPU 的浮点处理有细微差异。5.5 IP 白名单导致的“无权限”假象现象接口报无权限或请求来源 IP 不合法但证书、签名、商户号配置看起来全部正确。原因商户平台 API 安全里配置了调用来源 IP 白名单线上服务器出口 IP 不在白名单内或者联调环境 IP 变了没更新。解决在商户平台“API 安全”页面核对白名单列表把服务器出口 IP 加进去。这个坑的特点是“上午还好好的下午突然挂”十有八九是出口 IP 变了。6. 让转账功能经得起上线对账、幂等与留痕6.1 每日对账用查询接口把本地状态补齐异步通知有丢失可能所以不管回调多稳定都要有一个定时任务兜底。我一般写一个TransferCheckTask每天凌晨把前一天所有pending和partial的批次拿出来依次调queryBatch把微信返回的状态同步到本地。对账还有一个准则是“以微信侧为准”本地状态和微信不一致时微信是权威不要反着改。具体做法是先把本地明细表和微信返回的transfer_detail_list做比对逐笔核对金额和状态对不上的查日志找到原始请求报文、响应报文和回调原文。这个对账任务不需要跑得很频繁15 分钟一次足够覆盖大部分场景。6.2 请求留痕把每一笔的原始报文存下来我接手过一套商家转账功能代码没问题但就是查不出问题最后发现日志里只有“成功”“失败”两个词原始请求和响应全没存。排查资金问题没有原始报文等于盲人摸象。后来我把requestV3里每个环节都加了日志请求 URL、Authorization 头、请求体、HTTP 状态码、原始响应、耗时。回调那边同样存原文解密前和解密后各存一份。这套“留痕”逻辑用 ThinkPHP 的trace或者专用日志表都行关键是一旦有问题能拿原始报文去和微信官方文档逐字核对。我现在每次上线商家转账功能都会先在测试环境跑一轮“发起 → 查询 → 回调解密 → 对账”的完整闭环把微信返回的原始响应原样留档。这个习惯帮我挡掉过好几次“灵异问题”最后证实都是证书过期或环境差异。转账是资金操作宁可慢一点查清楚也别图省事跳步希望帮到你。本文还有配套的精品资源点击获取