WordPress 支付接入:网关选择、回调与订单状态
支付接入的难点不在「调起付款」。接一个网关,照文档做半天就能弹出二维码。真正出事的是付完钱之后:用户说付了,订单还是待付款,客服只能手工改单。
这类问题的根因大多不在网关,而在三件事:回调地址不通、订单状态机设计错、把前端返回页当成了支付结果。这篇按「选型 → 状态机 → 回调 → 排查 → 对账」的顺序写,不针对任何一家网关,讲的是接入结构。
先选网关类型,不是选品牌
从接入方式上分,WordPress 站的支付网关只有四类。选型看的是「收谁的钱、要不要多通道、有没有人力维护」,不是费率小数点后两位。
| 类型 | 接入方式 | 适用场景 | 代价 |
|---|---|---|---|
| 第三方聚合 | 对接一个服务商,由它分发到支付宝、微信等渠道 | 想一次接完多通道、没有支付资质、订单量中等 | 多一层依赖,结算周期和费率由服务商定,出问题要三方一起查 |
| 直连官方 | 直接对接支付机构的开放接口,按渠道分别接入 | 有营业执照、订单量大、要较低费率和较快到账 | 两套 SDK、两套密钥证书、两套对账,维护成本翻倍 |
| 境外收单 | 对接境外收单机构,收外币 | 面向海外用户,需要 Visa、Mastercard 等卡组织通道 | 涉及合规、结算币种、拒付处理,退款沟通成本高 |
| 线下核销 | 银行转账、对公汇款,后台人工确认 | B 端大额、订单量少 | 无法自动对账,需要人工核销与凭证归档 |
不管选哪类,WordPress 侧要写的结构是一样的:下单、跳转、回调、改状态、对账。差别只在签名算法和字段名。先把结构设计对,以后换网关只换适配层,不用重写业务。
订单状态机要先设计,再写代码
「支付成功但订单没变」十有八九是状态机没设计。一个能用的订单至少要这几个状态,并且规定清楚谁能写:
| 状态 | 含义 | 谁写入 | 允许流向 |
|---|---|---|---|
| pending | 已下单未支付 | 下单接口 | paid、closed、failed |
| paid | 已支付 | 只有回调或主动查单能写 | refunding、refunded |
| closed | 超时关闭 | 定时任务 | 不可逆 |
| refunding | 退款处理中 | 退款接口 | refunded、paid(失败回滚) |
| refunded | 已退款 | 退款回调 | 不可逆 |
两条铁律。第一,前端跳转回来的那个页面只用于展示,不能作为改状态的依据,用户可以伪造那个请求。第二,回调必须幂等,同一个通知可能重复到达五次,逻辑要写成「订单已经是 paid,直接返回成功,不做任何事」。
$order = get_order_by_no( $out_trade_no );
if ( ! $order ) { return 'fail'; }
if ( 'paid' === $order->status ) { return 'success'; } // 幂等
if ( ! verify_sign( $_POST, $gateway_key ) ) { return 'fail'; }
if ( (int) $order->amount !== (int) $notify_amount ) { return 'fail'; }
if ( 'pending' !== $order->status ) { return 'fail'; }
mark_order_paid( $order->id, $gateway_txn_id );
回调之外必须加一个主动查单。用户付完款关了页面、回调因为网络抖动丢了,靠定时任务每 5 分钟查一次 pending 超过 10 分钟的订单,能捞回大部分漏单。查单走网关的查询接口,不要靠猜,也不要人工补。
回调地址必须公网可达
这是最容易翻车的一条,而且测试环境永远测不出来。回调地址要满足:
- 公网能访问,不能是 localhost、127.0.0.1、内网 IP,也不能是需要改 hosts 才能解析的域名。
- 不需要登录、不需要 Cookie、不经过 Basic Auth。网关是用服务器身份来请求的,任何「先登录」的中间件都会把它挡在门外。
- 不能被 WAF、防火墙、安全插件拦。很多安全插件会把外部 POST 当攻击,直接返回 403,回调路径要加白名单。
- 不能被页面缓存接管。缓存插件把回调 URL 缓存成静态响应,网关拿到 200,但你的代码根本没执行,订单自然不会变。
- HTTPS 证书链完整。部分网关校验证书链,缺中间证书就握手失败,表现为网关一直重试但日志里没有请求。
上线前自己用 curl 打一次,确认请求能进到处理逻辑,而不是被 302 到登录页:
curl -i -X POST https://example.com/wp-json/mypay/v1/notify
-d "out_trade_no=TEST123&trade_status=TRADE_SUCCESS&sign=xxx"
看返回是不是预期的 fail(签名错)。如果返回 302、403,或者 200 但内容是一个 HTML 页面,说明请求在到达代码之前就被拦了。WordPress 的 REST 路由默认要 nonce,第三方回调必须在 register_rest_route 里显式把 permission_callback 设为返回 true 的函数,否则一律 401。
回调侧的安全底线
- 验签用网关提供的官方方法,不要自己拼字符串比对。编码差异和时序比较都会让自写验签出错,而且错得很隐蔽。
- 金额、商户号、商品在服务端按订单号重新取,只信订单号,不信回调里的价格和商品名。
- 日志脱敏,手机号、身份证、银行卡号不落明文,日志文件不要放在 web 可访问目录。
- 回调接口加频率限制,网关提供 IP 段的话加白名单,减少被刷。
「支付成功但订单没变」的四步排查
- 先去网关后台看这笔单的通知记录。多数网关会显示「已通知 N 次 / 返回内容」,显示失败或一直重试,问题在你这边。
- 再看服务器 access.log 有没有对应时间的 POST 请求。没有请求,说明网络层就断了:DNS、防火墙、HTTPS、路径写错,四个方向逐个排除。
- 有请求但状态码是 403 或 500,前者是被安全策略拦,后者是代码报错,直接看 debug.log。
- 请求 200 但订单没变,先查签名。常见错误是密钥用了测试环境的、签名串拼接顺序错、参数被二次 URL 编码、金额单位元与分没统一。签名没问题,再查状态机:订单可能已经 closed,回调来了被状态判断挡掉;也可能金额比对用了浮点数,0.1 加 0.2 不等于 0.3,校验直接失败。金额一律用整数分。
确认是回调丢失,不要手工改数据库了事。先把主动查单补上,再让回调与查单双通道并行,这类问题才算根治。手工改单可以救一单,救不了下一次。
测试模式与正式模式的切换
几乎所有网关都有沙箱和正式两套环境,地址、密钥、商户号、回调都不同。切换时按清单走,别只改密钥:
| 检查项 | 沙箱 | 正式 | 易错点 |
|---|---|---|---|
| 网关地址 | sandbox 域名 | 生产域名 | 只换密钥没换地址,请求全 404 |
| 应用 ID / 商户号 | 沙箱账号 | 正式商户号 | 混用导致签名对但商户不匹配 |
| 密钥与证书 | 沙箱密钥 | 正式密钥,注意证书到期时间 | 证书过期是最常见的突然全量失败 |
| 回调地址 | 测试域名 | 正式域名加 HTTPS | 正式环境还回调测试域名,永远收不到 |
| 金额 | 固定小额 | 真实金额 | 沙箱写死 0.01,上线忘了改 |
切换后先用自己的账号付一笔 1 分钱,把「下单 → 支付 → 回调 → 订单变 paid → 发货或开通 → 退款」整条链路走完,再放开给用户。这一步十分钟,能省掉后面几十个工单。
退款与对账
退款是异步的。调退款接口只代表受理成功,钱不一定到账,最终状态要看退款回调或者主动查询。所以退款要有独立状态,不能直接把订单改成 refunded。部分退款还要记录已退金额,避免多次退款累计超过原额。
对账每天做一次:拉网关的账单文件或对账接口,和自己的订单表按商户订单号比对,找出三类差异。网关有、本地没有的,是漏单;本地有、网关没有的,是假支付;金额不一致的,是改价或币种问题。差异行人工核,不要写脚本自动改状态,对账脚本本身出错会放大问题。
脚本注意两点:按「支付时间」而不是「下单时间」取区间,跨零点的订单才不会被漏掉;写入要幂等,重跑不重复记账。订单表按 created_at 建索引,回调日志单独一张表并定期归档,否则一年后几十万行,后台订单列表会明显变慢,这部分可以对照 数据库优化 做。
站上如果还涉及会员、课程、下载这类虚拟商品,权限校验必须放在支付成功之后,不能只靠前端隐藏按钮。自定义接口和钩子的写法见 插件定制,HTTPS 与证书链的配置见 SSL 配置,回调接口的防刷和日志脱敏属于 安全加固 的一部分,别等到出事再补。