PHP 支付集成实战:Stripe、支付宝与微信支付的状态机与回调

PHP 支付集成实战:支付流程与状态机设计、Stripe Checkout/PaymentIntent 与 Webhook 验签、支付宝异步通知 RSA2 验签、微信支付 v3 平台证书与 AES-GCM 回调解密、幂等设计与事件去重、对账与补偿、退款与争议处理、支付安全与合规要点。

引言

支付是少数「出错就是真金白银」的模块:少一次验签,可能被伪造回调白送商品;多一次重复处理,可能给用户发两遍货。PHP 生态里 Stripe、支付宝、微信支付三家网关的接入方式差异巨大——Stripe 靠 Webhook 签名,支付宝靠 RSA2 验签,微信支付 v3 还要 AES-GCM 解密。本文把三家的回调机制、幂等设计与对账方案讲透。

前置:安全加固、API 设计、队列与调度。


目录


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.completedCheckout 支付完成
payment_intent.succeededPaymentIntent 成功
charge.refunded已退款

记忆:Stripe Webhook = 原始 body + Webhook::constructEvent 验签 + event->id 幂等去重 + 按 event->type 分发;未订阅事件也要返回 200,否则 Stripe 会一直重试。


5. 支付宝异步通知与验签

5.1 通知参数

支付宝 POST 过来的表单参数包含:

参数说明
out_trade_no商户订单号
trade_no支付宝交易号
trade_statusTRADE_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 的差异

维度v2v3
签名MD5/HMAC-SHA256RSA-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。


延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「php」更多文章

  1. PHP 多租户 SaaS 架构:隔离策略、数据作用域与按租户计费
  2. PHP 的 CQRS 与事件溯源:命令总线、事件存储与投影
  3. Serverless PHP 与 Bref:Lambda 运行时、事件驱动与 Laravel Octane