做微擎模块开发,尤其是小程序商城的时候,微信支付基本是躲不掉的一环。很多刚上手的朋友以为接入支付就是调一下接口、配几个参数,真正做起来才发现签名规则、回调验签、金额精度、幂等更新,任何一个地方想当然都能让你在调试环境里熬到半夜。这篇文章聚焦后端篇,把小程序商城微信支付要走的路完整过一遍:从设计订单、统一下单、签名生成、回调处理,到退款、查单和常见问题定位,每段我都会结合自己实操中踩过的坑来写,尽量让你能照着落地。
这篇内容适合正在做微擎商城模块开发、或者刚接手支付类需求的人。如果你对微信支付只有一个模糊概念,先看这篇把后端流程串起来,再去对着官方接口文档看,会轻松很多。
1. 微信支付接入前,先把微擎模块的底子摸清
1.1 微擎模块里的“支付”和普通项目有什么不一样
很多人第一次在微擎模块里接微信支付时,还按普通 PHP 项目的习惯来:接口地址随便写、参数散落在控制器里、配置文件到处复制一份。结果一上线就发现,模块换个运行环境参数就读不到,或者回调地址生成得不对,微信那边根本通知不过来。
微擎的模块开发有自己的规则。模块的配置信息通常通过后台设置界面写入,代码里要通过对应的配置加载方式来取,而不是自己建一张表或者写一个 config.php 了事。原因很简单:微擎是多模块共存的体系,模块的启停、权限、配置都由框架统一管理。如果你绕开框架自己存参数,模块迁移、备份、重新安装的时候配置就丢了,后面维护起来相当难受。
再说目录结构。支付功能不要全部塞进控制器里。我在实操中习惯在模块目录下单独建一个 service 目录,专门放支付服务类。控制器只负责接收参数、校验参数、调用服务、返回结果,具体的签名逻辑、请求逻辑、回调逻辑都在服务层里处理。这样做的直接好处是,后面对接退款、查单、对账时,不需要把控制器翻个底朝天,而且逻辑也能复用。
1.2 小程序支付和公众号支付的后端差异
小程序商城走的支付方式,在微信支付体系里本质还是 JSAPI 支付,但和公众号网页支付有一个明显差别:小程序端的调用方式是wx.requestPayment,而公众号里通常是跳转支付页。后端要做的事情也有所不同。
小程序支付时,后端统一下单成功后会拿到prepay_id,但你不能把这个 id 直接丢给前端就完事,还需要把timeStamp、nonceStr、package、signType、paySign这五个参数一次性算好返回。前端拿到的是一组已经签好名的支付参数,直接丢给wx.requestPayment拉起收银台。这个二次签名很多人会漏掉,或者签名参数大小写搞错,导致前端一直报errMsg: "requestPayment:fail"。
另外,小程序端能拿到用户openid,后端统一下单时必须把它传进去。也就是说,商城模块在用户登录时就要维护好用户表和openid的绑定关系,不要在每次支付时才临时去查。这里也提醒一句:同一个用户在不同小程序下面openid是不同的,存的时候一定要连同小程序的appid一起存,别只存一个openid字段,否则以后接第二个小程序商城时会乱套。
2. 设计支付订单,先把金额和状态的规矩立起来
2.1 订单号不是随便拼的
微信支付对out_trade_no的要求是:商户系统内部的订单号,要求唯一,长度限制 32 个字符以内。很多人图省事直接date('YmdHis') . rand(100000, 999999),在并发不大的时候看着没问题,一旦商城搞一次秒杀活动,同一秒内多个订单撞号,支付单就串了。
我的做法是加前缀、带日期、带随机串:
function buildTradeNo($prefix = 'M') { return $prefix . date('YmdHis') . strtoupper(bin2hex(random_bytes(4))); }比如返回M20250614153012A1B2C3D4,长度在 24 位左右,满足微信支付限制,也不会暴露每天的订单量。前缀建议按业务场景区分,比如商城订单用M,退款单用R。退款单号单独有out_refund_no,不要偷懒直接拿原订单号去退款,否则同一订单退款两次时,微信那边直接报“商户退款单号重复”。
2.2 金额为什么必须用“分”来算
微信支付接口里,total_fee的单位是“分”,不是“元”。这个坑几乎是初学者必踩。更麻烦的是,如果用 PHP 的浮点数去计算金额,0.1 + 0.2都能得到0.30000000000000004,你把这样的数乘以 100 转成整数,结果可能是30.000000000000004,再强转成 int 就会出现金额少 1 分甚至多 1 分的现象。
所以商城后端在设计表结构时,金额字段我强烈建议直接存整数分,而不是小数元。前端传过来的金额一律按分接收,所有计算都用整数运算。如果确实遇到了元转分的场景,不要用(int)($amount * 100),稳妥一点:
function yuanToFen($amount) { return (int) round((float) $amount * 100); }这里用round处理四舍五入,可以规避大部分浮点误差。但这只是兜底方案,最好的做法还是从头到尾都是分,不涉及转换。
2.3 支付状态别用一两个字段硬扛
商城订单的状态不能只搞一个status字段。支付状态和订单业务状态混在一起,后面对账、退款、发货都会很痛苦。我习惯至少拆成两个字段:
order_status:业务状态,比如待发货、已发货、已完成、已取消。pay_status:支付状态,比如 0 待支付、1 已支付、2 已退款、3 已关闭。
这样做的好处是,支付回调只需要更新pay_status,不会误伤order_status。比如用户下单后未支付,你让order_status = 0表示待付款;支付成功后把pay_status改成 1,但order_status可以由后续的发货流程单独推进。如果只用一个字段,支付回调来了你要判断当前状态能不能流转,逻辑会很绕,而且容易把已取消的订单重新激活。
另外强调一点:支付回调可能不是按业务顺序到达的。比如用户先取消订单,但微信支付的通知晚了几秒才到,如果你在回调里不判断当前订单状态,直接无脑改成已支付,那这笔订单就变成“已取消但已支付”,对账的时候根本解释不清楚。所以更新订单时一定要带条件,见后面的回调处理部分。
3. 统一下单,把签名、请求和返回一次讲透
3.1 配置读取与服务类的封装
在微擎模块里,支付配置我建议做成一个服务类,构造函数接收配置数组:
class WxPayService { protected $config; public function __construct($config = []) { $this->config = [ 'appid' => $config['appid'] ?? '', 'mch_id' => $config['mchid'] ?? '', 'api_key' => $config['apiv2key'] ?? '', 'notify_url' => $config['notify_url'] ?? '', 'cert_path' => $config['cert_path'] ?? '', 'key_path' => $config['key_path'] ?? '', ]; } }控制器从微擎配置接口里把配置数组取出来,new 一个服务类,然后调用下单选单等方法。不要在每个方法里都重新读一遍配置,也不要让控制器直接去拼接签名参数。后面如果要从 MD5 切换到 HMAC-SHA256,或者换支付渠道,只需要改服务类内部实现,控制器基本不用动。
3.2 签名规则:参数先排序,拼上密钥再加密
微信支付 API v2 的签名规则是:把参数按照键名 ASCII 码从小到大排序,排除空值和sign本身,然后按key=value&key=value的形式拼接,最后在末尾拼上&key=商户API密钥,再做 MD5,转成大写。
private function makeSign($params, $apiKey, $signType = 'MD5') { // 过滤空值 $params = array_filter($params, function ($v) { return $v !== '' && $v !== null; }); unset($params['sign']); // ASCII 排序 ksort($params); $stringA = ''; foreach ($params as $k => $v) { $stringA .= $k . '=' . $v . '&'; } $stringA .= 'key=' . $apiKey; if ($signType === 'HMAC-SHA256') { return strtoupper(hash_hmac('sha256', $stringA, $apiKey)); } return strtoupper(md5($stringA)); }这里有个细节很多人会忽略:拼接时不要对值做 URL 编码。微信支付文档里虽然提过 URL 编码,但实际生成签名时,参与签名的字符串是原始值。你如果在拼接前对商品名之类的中文做了urlencode,自己验签的时候会发现怎么都对不上。
还有,sign_type这个参数本身也参与签名。如果你在参数里加了sign_type=MD5,排序和拼接时都要包含它。如果商户后台设置的密钥类型是 MD5,但请求里写 HMAC-SHA256,签名结果也会对不上,这一点最容易让人掉坑。
3.3 发起统一下单请求
微信支付统一下单接口地址是固定的,请求体是 XML,不是 JSON。很多 PHP 开发者习惯了数组转 JSON,第一次转 XML 会不知所措。其实结构很简单,就是一层层的<key>value</key>。
public function unifyOrder($order, $openid) { $params = [ 'appid' => $this->config['appid'], 'mch_id' => $this->config['mch_id'], 'nonce_str' => md5(uniqid()), 'sign_type' => 'MD5', 'body' => $order['body'], // 商品描述 'out_trade_no' => $order['trade_no'], // 商户订单号 'total_fee' => $order['amount'], // 金额,单位分 'spbill_create_ip' => $this->getClientIp(), 'notify_url' => $this->config['notify_url'], 'trade_type' => 'JSAPI', 'openid' => $openid, ]; $params['sign'] = $this->makeSign($params, $this->config['api_key']); $xml = $this->arrayToXml($params); $response = $this->postXml('https://api.mch.weixin.qq.com/pay/unifiedorder', $xml); $data = $this->xmlToArray($response); if (($data['return_code'] ?? '') !== 'SUCCESS') { throw new \Exception($data['return_msg'] ?? '统一下单失败'); } if (($data['result_code'] ?? '') !== 'SUCCESS') { throw new \Exception($data['err_code_des'] ?? '统一下单业务失败'); } return $data['prepay_id']; }postXml本质上就是一次带证书可选的 POST 请求。调试期为了省事很多人会设置CURLOPT_SSL_VERIFYPEER => false,这个在本地没问题,但生产环境建议打开证书校验,否则一旦服务器 CA 证书过期,请求会静默失败。
XML 转数组时,我用simplexml_load_string配合LIBXML_NOCDATA:
libxml_disable_entity_loader(true); $xmlObj = simplexml_load_string($xml, 'SimpleXMLElement', LIBXML_NOCDATA); $data = json_decode(json_encode($xmlObj), true);libxml_disable_entity_loader(true)是为了防止 XXE 实体注入,服务端解析外部传入的 XML 时必须加这一句,安全无小事。
3.4 给前端生成小程序支付参数
拿到prepay_id之后,后端还要做一次加签,生成小程序端真正要用的参数:
$nonceStr = md5(uniqid()); $payParams = [ 'appId' => $this->config['appid'], 'timeStamp' => (string) time(), 'nonceStr' => $nonceStr, 'package' => 'prepay_id=' . $prepayId, 'signType' => 'MD5', ]; $payParams['paySign'] = $this->makeSign($payParams, $this->config['api_key']);注意timeStamp我特意转成了字符串。因为小程序端在校验签名时,如果后端传的是数字类型,部分基础库版本对类型判断不一致,可能导致签名校验失败。这是一个很隐蔽的细节,但实际项目里确实遇到过。
返回给前端的数据结构可以统一成:
{ "code": 0, "msg": "ok", "data": { "timeStamp": "1750000000", "nonceStr": "abc123", "package": "prepay_id=wx...", "signType": "MD5", "paySign": "ABCDEF..." } }不要画蛇添足把prepay_id本身也返回给前端,前端拉支付只需要package里包含它即可。
4. 回调处理,支付对接里最能拉开差距的部分
4.1 先验签,再验金额,顺序不能乱
支付回调是微信服务器主动 POST 到你的notify_url,内容是一段 XML。很多人的第一版回调是这样的:收到数据,直接查订单,直接改成已支付,完事。这种写法在测试环境可能碰巧能用,上线之后迟早出事。
正确的处理顺序,我列为死规矩:
- 接收原始报文,转成数组。
- 先验签,验签不过直接输出
FAIL。 - 验签通过后,检查
return_code和result_code是否都为SUCCESS。 - 用
out_trade_no查出本地订单。 - 比对回调里的
total_fee和本地订单金额是否一致。 - 一致则幂等更新订单,不一致则记录异常并输出
FAIL。
验签代码和统一下单的签名逻辑完全一致:
public function verifyNotify($data) { $sign = $data['sign'] ?? ''; unset($data['sign']); $mySign = $this->makeSign($data, $this->config['api_key']); return $mySign === $sign; }这里容易踩坑的点是:回调数组里除了sign字段,其他都参与签名,包括空值也要过滤。有些字段微信没返回,你就不要自己补默认值进去,否则签名校验必挂。
4.2 金额比对,别只验证签就放行
为什么验签通过还要验证金额?因为签名只能证明“这包数据确实是微信支付发出的”,不能证明“这笔钱确实是这个订单该付的钱”。万一订单被篡改,或者数据库被异常修改,回调里的金额和本地订单对不上,你还傻乎乎地把订单标记成已支付,财务对账时就是一笔脏账。
金额比对失败的场景,实际中比较少见,但一旦出现基本都是大问题。我遇到过一种情况:用户在旧版本小程序下单时金额传的是元,后端也按元存了库,升级后新版本按分存储,回调金额对不上,排查了半天才发现是历史数据格式不一致。所以要做兼容迁移,不能只改新代码,老数据也得清洗干净。
4.3 幂等更新:同一笔回调来了十次,订单也只能更新一次
微信支付官方文档明确说,通知可能会多次发送,频率大概是 15 秒、30 秒、3 分钟、10 分钟……各不相同。你的回调接口必须做好幂等处理。
最简单的做法是带条件的更新:
$updated = pdo_update( $orderTable, [ 'pay_status' => 1, 'transaction_id' => $data['transaction_id'], 'pay_time' => time(), ], [ 'out_trade_no' => $outTradeNo, 'pay_status' => 0, ] ); if ($updated) { // 只有这里才执行发货、加积分、写日志等业务动作 }如果pay_status已经不是 0,说明订单之前已经处理过了,这次回调直接原样返回SUCCESS即可,微信收到成功后就不会再继续重试。这样就算同一笔回调并发来了两次,也不会重复发货。数据库更新本身就是行级原子操作,比在应用层加锁简单可靠。
4.4 返回 SUCCESS 的时机和格式
回调处理完业务之后,需要输出一个字符串,微信收到后会认为通知成功。注意两点:一是只能输出SUCCESS或FAIL两个单词,不能带 HTML、空格、换行;二是一定要在所有业务处理完成之后再输出,中途任何异常都应该输出FAIL,让微信过一会儿再重试。
try { // 验签、验金额、幂等更新 echo 'SUCCESS'; } catch (\Exception $e) { // 记录日志 echo 'FAIL'; } exit();这个exit()不能省,防止框架在请求结束后继续输出调试信息、SQL 日志之类的内容,那些都会破坏响应体,导致微信端一直认为通知失败。
注意:回调地址必须是公网可访问的 URL,而且不能带
?查询参数。微信支付文档要求notify_url不能有 query string,你可以通过路径区分不同业务,但别通过参数区分。这算是一个比较经典的低级坑。
4.5 回调日志,钱的事必须有记录可查
做支付回调,我不建议只靠内存里error_log就完事。每一笔回调请求,至少要记录:
- 原始请求报文(XML)
- 验签结果
- 本地订单查询结果
- 金额比对结果
- 更新 SQL 影响行数
日志文件建议按天切割,放模块的 runtime 目录下。不要全打在一个文件里,否则一天下来几十万条数据,排查问题的时候翻日志想死。
实际工作中,“支付成功但订单没更新”这类问题,十个里有八个是回调根本没到后端,不是业务逻辑写错了。没有日志,你根本不知道是微信没通知,还是通知了但验签失败。有了日志,一查便知。
5. 退款与查单,把支付闭环补完整
5.1 退款接口:证书和参数字段都要改
用户申请退款是商城的高频操作。微信支付的退款接口和统一下单有几个明显区别:
- 接口地址不同:
https://api.mch.weixin.qq.com/secapi/pay/refund - 需要双向证书:
CURLOPT_SSLCERT和CURLOPT_SSLKEY - 必须传
out_refund_no(商户退款单号) - 金额相关有
total_fee和refund_fee两个字段
退款请求示例:
public function refund($order, $refundNo, $refundFee) { $params = [ 'appid' => $this->config['appid'], 'mch_id' => $this->config['mch_id'], 'nonce_str' => md5(uniqid()), 'sign_type' => 'MD5', 'transaction_id' => $order['transaction_id'], 'out_trade_no' => $order['trade_no'], 'out_refund_no' => $refundNo, 'total_fee' => $order['amount'], 'refund_fee' => $refundFee, ]; $params['sign'] = $this->makeSign($params, $this->config['api_key']); $xml = $this->arrayToXml($params); $ch = curl_init('https://api.mch.weixin.qq.com/secapi/pay/refund'); curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, $xml); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_SSLCERT, $this->config['cert_path']); curl_setopt($ch, CURLOPT_SSLKEY, $this->config['key_path']); // 其他 curl 参数... }证书文件是从微信支付商户平台下载的apiclient_cert.pem和apiclient_key.pem,下载后一定要放到 web 根目录之外、PHP 进程可读的位置。不要放到商城附件目录,否则别人直接下载你的商户私钥文件,后果不堪设想。
另外,退款金额必须小于等于订单实付金额,可以部分退款,也可以全额退款。退款成功后,微信会异步通知退款结果,notify_url可以在商户平台配置,也可以走退款接口的notify_url参数。和后端处理支付回调一样,退款回调本身也要验签、验金额、幂等更新支付状态。
5.2 主动查单,给掉单一个兜底机会
微信支付通知并不是 100% 可靠,总有极端情况:服务器短暂宕机、回调超时、运维误杀进程……所以商城要做主动查单机制。
微信支付提供了订单查询接口:https://api.mch.weixin.qq.com/pay/orderquery。参数很简单,appid、mch_id、out_trade_no、nonce_str、sign。请求后如果能查到微信侧订单已支付,而本地pay_status还是 0,就可以自动执行补单逻辑,把订单状态修正过来。
我习惯的做法是写一个定时任务脚本,每 5 分钟扫描一次“已下单但超过 5 分钟仍未支付成功”的订单,逐个去微信查单。注意频率不要太快,否则订单量大的时候,商户平台 API 调用量会涨得飞快,而且大部分未支付订单其实也没必要查太多次,查一次确认没支付就放弃即可。
6. 常见问题定位与避坑实录
最后把我这几年在支付对接中真实遇到过的问题整理成一张速查表,按现象、可能原因、排查方向列出来,方便你在现场快速定位。
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 统一下单返回签名错误 | 参与签名的参数格式有误、密钥不对、参数名拼写错误 | 打印出拼接的签名串,核对排序规则和 key |
| 前端拉起支付失败 | 二次签名时timeStamp类型不一致、参数大小写不对 | 比对后端生成的paySign是否和前端重新计算的一致 |
| 支付成功但订单状态未更新 | 回调未到达、验签失败、幂等条件更新没命中 | 查回调日志,看是否存在验签记录和更新影响行数 |
| 回调频繁重试 | 返回内容不是合法的SUCCESS,或者响应里掺杂了额外输出 | 抓响应体,确认只输出一个SUCCESS字符串 |
| 支付金额差一分钱 | 前后端金额单位不统一,或者浮点运算导致精度丢失 | 全链路改成整数分存储和计算,禁止浮点金额 |
| 退款报证书错误 | 证书路径不对、文件权限不够、证书放到了别的环境 | 检查CURLOPT_SSLCERT和CURLOPT_SSLKEY路径可读性 |
回调收到FAIL但本地没报错 | 框架在 echo 后追加输出,或者异常被全局捕获 | 加exit(),并在控制器入口处捕获框架错误 |
本地调试没有公网回调地址时,一个很实用的办法是手动模拟回调。你可以先用测试号真实走一遍支付流程,把微信后台记录到的通知报文保存下来,然后直接 POST 到本地回调地址,配合日志定位问题。记得把报文里的金额和本地订单保持一致,否则会因为金额比对失败而看不到后续逻辑。这种方式调试验签和幂等更新特别高效,比反复让微信重试舒服多了。
在实际项目里,我最深的体会是:支付功能做得多了,真正值钱的不是第一次跑通接口,而是面对异常时你知道去哪里查、查什么字段、下一步怎么处理。把签名、回调、金额、幂等这四件事在代码里固定成一套不容变更的流程,比任何文档都管用。希望这篇后端篇能帮你少走几个我走过的弯路。