引言
支付是少数「出错就是真金白银」的模块:少一次验签,可能被伪造回调白送商品;多一次重复处理,可能给用户发两遍货。PHP 生态里 Stripe、支付宝、微信支付三家网关的接入方式差异巨大——Stripe 靠 Webhook 签名,支付宝靠 RSA2 验签,微信支付 v3 还要 AES-GCM 解密。本文把三家的回调机制、幂等设计与对账方案讲透。
目录
- 1. 支付系统全景与状态机
- 2. 支付流程:从下单到回调
- 3. Stripe:Checkout 与 PaymentIntent
- 4. Stripe Webhook 验签与幂等
- 5. 支付宝异步通知与验签
- 6. 微信支付 v3:证书与回调
- 7. 幂等、对账与补偿
- 8. 退款、争议与风控
- 9. 安全与合规要点
- 10. 速查表与一句话记忆
- 延伸阅读
1. 支付系统全景与状态机
1.1 支付状态机
所有支付网关的本质都是一台状态机。本地订单状态应当只由回调驱动推进:
CREATED ──下单──▶ PENDING ──支付成功──▶ PAID ──发起退款──▶ REFUNDING ──▶ REFUNDED
│ │
│ └──超时/失败──▶ CLOSED / FAILED
└──未支付超时──▶ CLOSED
1.2 三条铁律
| 铁律 | 原因 |
|---|---|
| 状态只由回调推进 | 前端「支付成功」页面可伪造,不可信 |
| 一切回调必须验签 | 否则任何人都能伪造「已付款」 |
| 一切回调必须幂等 | 网关会重试,同一事件可能到达多次 |
1.3 关键字段约定
| 字段 | 含义 | 谁生成 |
|---|---|---|
| out_trade_no | 商户订单号 | 自己(全局唯一) |
| trade_no | 网关交易号 | 网关 |
| total_amount | 金额(分/元) | 自己,回调时须比对 |
| trade_status | 交易状态 | 网关 |
记忆:支付 = 一台只由回调驱动的状态机(CREATED→PENDING→PAID→REFUNDED);三条铁律——状态只信回调、回调必验签、回调必幂等。
2. 支付流程:从下单到回调
2.1 标准四步
① 下单:后端生成 out_trade_no,落库为 PENDING
② 创建支付:调网关拿到跳转 URL / 客户端参数,返回前端
③ 用户支付
④ 网关异步通知后端 → 验签 → 更新为 PAID → 发货
2.2 同步跳回 vs 异步通知
| 维度 | 同步跳回(return_url) | 异步通知(notify_url) |
|---|---|---|
| 触发 | 用户浏览器跳转 | 网关服务器回调 |
| 可信度 | 不可信(可伪造) | 可信(验签后) |
| 用途 | 展示结果页 | 唯一的订单状态来源 |
| 失败重试 | 无 | 网关按策略重试 |
常见错误:只在同步跳回里把订单改成已支付——用户关掉页面就永远收不到货。正确做法是同步跳回只做「跳转展示」,状态一律等异步通知。
2.3 本地订单号设计
订单号要做到全局唯一、可追溯、不泄露业务量,典型拼法是「时间戳 + 补零订单 id + 随机数」。落库时给 out_trade_no 建唯一索引——这是幂等的第一道防线。
记忆:下单四步(生成单号→创建支付→用户支付→异步通知);同步跳回只做展示、异步通知才是状态唯一来源;out_trade_no 全局唯一并建唯一索引。
3. Stripe:Checkout 与 PaymentIntent
3.1 两种接入方式
| 方式 | 适合 | 特点 |
|---|---|---|
| Checkout Session | 快速接入 | Stripe 托管收银台,PCI 负担最小 |
| PaymentIntent + Elements | 自定义 UI | 完全控制前端,复杂度更高 |
3.2 创建 Checkout Session
$stripe = new \Stripe\StripeClient(config('services.stripe.secret'));
$session = $stripe->checkout->sessions->create([
'mode' => 'payment',
'line_items' => [[
'price_data' => [
'currency' => 'usd',
'unit_amount' => 2000, // 单位:分
'product_data' => ['name' => 'Pro 会员'],
],
'quantity' => 1,
]],
'success_url' => route('pay.success') . '?session_id={CHECKOUT_SESSION_ID}',
'client_reference_id' => $outTradeNo, // 回传自己的订单号
'metadata' => ['order_id' => $order->id],
], ['idempotency_key' => 'order_' . $order->id]); // 幂等键,防重复创建
3.3 PaymentIntent(自定义 UI)
$intent = $stripe->paymentIntents->create([
'amount' => 2000,
'currency' => 'usd',
'automatic_payment_methods' => ['enabled' => true],
'metadata' => ['order_id' => $order->id],
]);
// 前端用 $intent->client_secret 调 Stripe.js 完成支付
idempotency_key 很关键:网络抖动导致的重试不会创建两笔支付。
记忆:Stripe 两条路——Checkout Session 快速接入、PaymentIntent + Elements 自定义 UI;两者都要带
client_reference_id/metadata回传订单号,并用idempotency_key防重复创建。
4. Stripe Webhook 验签与幂等
4.1 验签是硬要求
use Stripe\Webhook;
use Stripe\Exception\SignatureVerificationException;
$payload = file_get_contents('php://input'); // 原始 body,切勿先 json_decode
$sigHeader = $_SERVER['HTTP_STRIPE_SIGNATURE'] ?? '';
try {
$event = Webhook::constructEvent($payload, $sigHeader, config('services.stripe.webhook_secret'));
} catch (SignatureVerificationException $e) {
abort(400, 'invalid signature');
}
务必用原始 body——任何 json_decode 再 json_encode 都会改变字节,导致验签失败。
4.2 处理事件 + 幂等
if (ProcessedEvent::where('event_id', $event->id)->exists()) {
return response('ok'); // 幂等:已处理,直接返回 200
}
match ($event->type) {
'checkout.session.completed',
'payment_intent.succeeded' => OrderService::markPaid(
$event->data->object->metadata->order_id,
$event->data->object->amount_received,
),
'charge.refunded' => RefundService::handleRefund($event->data->object),
default => null, // 未订阅的事件直接忽略
};
ProcessedEvent::create(['event_id' => $event->id]);
return response('ok');
4.3 常用事件
| 事件 | 含义 |
|---|---|
| checkout.session.completed | Checkout 支付完成 |
| payment_intent.succeeded | PaymentIntent 成功 |
| charge.refunded | 已退款 |
记忆:Stripe Webhook = 原始 body +
Webhook::constructEvent验签 +event->id幂等去重 + 按event->type分发;未订阅事件也要返回 200,否则 Stripe 会一直重试。
5. 支付宝异步通知与验签
5.1 通知参数
支付宝 POST 过来的表单参数包含:
| 参数 | 说明 |
|---|---|
| out_trade_no | 商户订单号 |
| trade_no | 支付宝交易号 |
| trade_status | TRADE_SUCCESS / TRADE_FINISHED |
| total_amount | 订单金额 |
| sign / sign_type | 签名与算法(RSA2) |
5.2 验签实现
function alipayVerify(array $params, string $alipayPublicKey): bool
{
$sign = $params['sign'];
unset($params['sign'], $params['sign_type']);
ksort($params); // 1. 按键名排序
$pairs = [];
foreach ($params as $k => $v) {
if ($v !== '' && $v !== null) { $pairs[] = $k . '=' . $v; } // 2. 拼 k=v
}
$content = implode('&', $pairs); // 3. 待验签串
return openssl_verify($content, base64_decode($sign),
$alipayPublicKey, // 支付宝公钥(非应用公钥)
OPENSSL_ALGO_SHA256 // 4. RSA2 = SHA256
) === 1;
}
5.3 处理与应答
if (!alipayVerify($_POST, $publicKey)) {
echo 'fail'; // 验签失败,让支付宝重试
return;
}
if (in_array($_POST['trade_status'], ['TRADE_SUCCESS', 'TRADE_FINISHED'], true)) {
OrderService::markPaid($_POST['out_trade_no'], $_POST['total_amount']);
}
echo 'success'; // 必须返回纯文本 success
注意:金额要比对(total_amount 与自己订单一致),不能只信任回调里的值;返回非 success 支付宝会持续重试。
记忆:支付宝验签 = 去掉 sign/sign_type → 参数按 key 排序 → 拼 k=v&k=v →
openssl_verify(content, base64_decode(sign), 支付宝公钥, SHA256);处理完必须回success,且要校验金额。
6. 微信支付 v3:证书与回调
6.1 与 v2 的差异
| 维度 | v2 | v3 |
|---|---|---|
| 签名 | MD5/HMAC-SHA256 | RSA-SHA256 + 平台证书 |
| 回调数据 | XML 明文 | JSON + AES-256-GCM 加密 |
6.2 回调验签
$timestamp = $_SERVER['HTTP_WECHATPAY_TIMESTAMP'];
$nonce = $_SERVER['HTTP_WECHATPAY_NONCE'];
$signature = $_SERVER['HTTP_WECHATPAY_SIGNATURE'];
$body = file_get_contents('php://input');
$message = "{$timestamp}\n{$nonce}\n{$body}\n";
$ok = openssl_verify($message, base64_decode($signature), $platformCert, OPENSSL_ALGO_SHA256) === 1;
6.3 解密 resource
$payload = json_decode($body, true)['resource'];
$ciphertext = base64_decode($payload['ciphertext']);
$tag = substr($ciphertext, -16); // 末 16 字节是 GCM tag
$data = substr($ciphertext, 0, -16);
$plain = openssl_decrypt($data, 'aes-256-gcm', config('wechat.apiv3_key'),
OPENSSL_RAW_DATA, $payload['nonce'], $tag, $payload['associated_data']);
$notify = json_decode($plain, true); // out_trade_no / trade_state
6.4 应答格式
if ($notify['trade_state'] === 'SUCCESS') {
OrderService::markPaid($notify['out_trade_no'], $notify['amount']['total']);
}
return response()->json(['code' => 'SUCCESS', 'message' => '成功']); // HTTP 200
微信要求 HTTP 200 + code: SUCCESS,否则按策略重试。APIv3 密钥与商户私钥必须放 KMS/Secrets Manager,绝不进代码库。
记忆:微信 v3 = 平台证书 RSA-SHA256 验签(timestamp+nonce+body)+ AES-256-GCM 解密 resource(末 16 字节是 tag)+ 回 200 且 code=SUCCESS;APIv3 密钥与商户私钥进密钥管理服务。
7. 幂等、对账与补偿
7.1 三层幂等
| 层级 | 手段 |
|---|---|
| 下单 | out_trade_no 唯一索引 |
| 回调 | 事件 id / trade_no 唯一索引 |
| 业务 | 状态机只允许 PENDING→PAID 一次 |
// 乐观更新:只有当前是 PENDING 才改成 PAID,天然幂等
$affected = Order::where('id', $id)->where('status', 'PENDING')
->update(['status' => 'PAID', 'paid_at' => now()]);
// $affected === 0 说明已支付过或状态不对——记录日志但不报错
7.2 对账
每天拉取网关对账单(支付宝 alipay.data.dataservice.bill.downloadurl.query,微信 downloadbill),与本地订单做逐笔比对:
一致 → 跳过
网关有本地无 → 补单(漏单告警)
本地有网关无 → 查是否未支付成功,必要时关单
金额不一致 → 高优告警 + 人工介入
7.3 补偿
回调丢失时,用主动查询兜底:定时任务对「PENDING 且超过 5 分钟」的订单调 alipay.trade.query / 微信查单接口,查到成功就补状态。
记忆:幂等三层(单号唯一索引 + 事件唯一索引 + 状态机乐观更新);对账靠每日账单逐笔比对(一致/补单/关单/金额异常);补偿靠定时主动查单兜底丢回调。
8. 退款、争议与风控
8.1 退款流程
用户申请退款 → 校验(金额≤实付、时间窗、次数)→ 调网关退款接口
→ 退款是异步的 → 等退款回调 → 更新为 REFUNDED
Stripe 用 $stripe->refunds->create(['payment_intent' => $pi, 'amount' => 500]);支付宝用 alipay.trade.refund;微信 v3 用 /v3/refund/domestic/refunds。退款同样要幂等(用 out_request_no 作为退款单号)。
8.2 争议(Chargeback)
信用卡拒付(dispute/chargeback)由银行发起,流程与普通退款不同:
| 阶段 | 动作 |
|---|---|
| 收到 dispute 通知 | 冻结相关资金,准备证据 |
| 提交证据 | 物流、聊天记录、IP、签名 |
| 裁决 | 胜诉返还,败诉扣款 + 罚金 |
8.3 基础风控
最简单的风控是限流:用 Redis 统计「同用户短时间内的支付失败次数」,超过阈值(如 5 次 / 10 分钟)就返回 429。对高风险订单可接入 Stripe Radar 或自建规则引擎(金额阈值、地域、设备指纹)。
记忆:退款异步且要幂等(out_request_no);争议是银行侧拒付、需举证;风控从「失败次数限流」起步,高风险接入 Radar 或规则引擎。
9. 安全与合规要点
| 要点 | 做法 |
|---|---|
| 密钥管理 | KMS / Secrets Manager,禁止硬编码与进 Git |
| 回调验签 | 三家全部强制,失败即拒 |
| 金额校验 | 以自己订单金额为准,回调金额仅作比对 |
| 日志脱敏 | 卡号/手机号/身份证脱敏后再落日志 |
| PCI DSS | 用 Checkout/Elements 托管收银台可降低合规范围 |
9.1 一个高频漏洞
金额信任:前端传 amount=1 就按 1 元下单。正确做法是后端根据商品/订单重新计算金额——$order->items->sum(fn ($i) => $i->price * $i->qty),前端传来的金额一律忽略。
记忆:支付安全六件事——密钥进 KMS、回调必验签、金额以自己订单为准、日志脱敏、全站 HTTPS、优先托管收银台降 PCI 范围;最大的坑是「信任前端金额」。
10. 速查表与一句话记忆
| 需求 | 做法 |
|---|---|
| 状态推进 | 只由验签后的异步通知驱动 |
| Stripe 验签 | Webhook::constructEvent(原始 body) |
| 支付宝验签 | 排序拼串 + openssl_verify(SHA256) |
| 支付宝应答 | 回纯文本 success |
| 微信 v3 验签 | RSA-SHA256(ts+nonce+body) |
| 微信解密 | AES-256-GCM,末 16 字节为 tag |
| 幂等 | 单号/事件唯一索引 + 状态机乐观更新 |
| 对账 | 每日账单逐笔比对 + 主动查单补偿 |
一句话记忆:PHP 支付集成的核心是「状态只信验签后的异步通知」——Stripe 用原始 body 走 constructEvent 验签、支付宝去 sign 后排序拼串 openssl_verify、微信 v3 用平台证书验签再 AES-256-GCM 解密 resource;三层幂等(单号/事件唯一索引 + 状态机乐观更新)挡住重复回调,每日账单对账 + 主动查单兜底丢单,退款与争议同样要幂等举证,密钥一律进 KMS。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。