PHP如何对接Stripe支付并实现订阅扣费与Webhook回调

好,各位未来的全栈大师,欢迎来到今天的“PHP与Stripe的爱恨情仇”研讨会。别紧张,我们不搞那些虚头巴脑的“区块链”或者“元宇宙”,咱们今天就来聊聊怎么用PHP这个老牌劲旅,去驾驭Stripe这个支付界的“华尔街之狼”。

为什么要用Stripe?因为它快、因为它准、因为它不需要你自己去管银行那帮挑剔的柜员。用PHP?因为PHP依然是Web开发界的“瑞士军刀”,虽然有人说它老了,但只要你能驾驭它,它依然是处理后台逻辑最犀利的武器。

今天的讲座主题只有一个:如何用PHP构建一个坚不可摧的订阅支付系统。

为了让我们这堂课不至于变成催眠曲,我会把内容拆解成三个阶段:“搞清楚你在卖什么(产品定义)”“前端怎么骗用户掏钱(界面交互)”“后台怎么接住钱(PHP核心逻辑与Webhook)”

准备好了吗?系好安全带,我们出发。


第一部分:工欲善其事——环境准备与API密钥

在写代码之前,我们得先搞清楚武器在哪。Stripe的PHP SDK其实就像是一个打包好的工具箱,我们得先把它买回来。

1. 安装“武器”

别去GitHub手动下载那个死沉的库,用Composer。Composer是PHP的包管理器,就像是你家里的垃圾桶,虽然有时候不想要,但它把东西整理得井井有条。

在你的项目根目录下,打开终端,敲下这句咒语:

composer require stripe/stripe-php

2. API密钥:你的身份证

Stripe给开发者提供两张身份证:Publishable Key(公钥)Secret Key(私钥)

  • 公钥:发给别人看,别藏着掖着,把它贴在前端HTML里,用来让Stripe弹出支付框。
  • 私钥:你的底牌,绝对不能暴露给前端!绝对不能!把它放在PHP的后端代码里,它是我们跟Stripe服务器对话的“暗号”。

在你登录Stripe Dashboard的时候,你会看到这两把钥匙。记住了吗?记不住你就等着报错吧。


第二部分:定义产品——别上来就跳钱,先告诉别人你卖啥

Stripe很聪明,它不关心“我要收100块钱”,它关心的是“我要卖什么商品”。所以,我们必须先创建一个Product(产品)和一个Price(价格)。

为什么要有两个对象?因为Stripe的设计哲学是“一切皆对象”。产品是死的,价格是活的。如果以后你想涨价,你只需要改Price,不用改Product;如果以后你想换个商品,你只需要改Product,不用改所有价格。

代码示例:创建产品和价格

我们写个简单的PHP脚本,假装我们在卖一个“年度高级会员订阅”。

<?php
require 'vendor/autoload.php';

// 1. 初始化Stripe客户端,把你的Secret Key塞进去
StripeStripe::setApiKey('sk_test_your_secret_key_here');

try {
    // 2. 创建产品
    $product = StripeProduct::create([
        'name' => '极客PHP大师班 - 年度会员',
        'description' => '包含所有视频教程和源码,终身有效。',
    ]);

    // 3. 创建价格
    // recurring:表示这是订阅
    // interval:扣费周期
    // currency:货币单位
    $price = StripePrice::create([
        'product' => $product->id, // 关联到刚才创建的产品
        'unit_amount' => 199900,   // 金额,单位是分(CNY:1999.00元 = 199900分)
        'currency' => 'cny',       // 人民币
        'recurring' => [
            'interval' => 'year', // 每年扣一次
        ],
        'billing_scheme' => 'per_unit', // 每单位计费
    ]);

    echo "产品创建成功!ID: " . $product->id . "<br>";
    echo "价格创建成功!ID: " . $price->id . "<br>";

    // 为了演示,我们打印出返回的JSON结构,让你看看Stripe喜欢怎么唠叨
    echo "<pre>";
    print_r(json_encode(['product' => $product, 'price' => $price], JSON_PRETTY_PRINT));
    echo "</pre>";

} catch (Error $e) {
    echo '发生错误: ' . $e->getMessage();
}

专家点评
注意看 unit_amount,单位是。如果你写了199900,那是1999元。如果你写错了,Stripe不会告诉你“你钱给少了”,它会直接告诉你“余额不足”或者“无效金额”。这是新手最容易踩的坑,记住了:


第三部分:前端交互——让用户乖乖刷卡

现在我们有了产品和价格,接下来得有个地方让用户输入卡号。如果你自己去写一个输入框来校验卡号,那简直是对人类智商的侮辱。你会遇到校验算法(Luhn算法)、脱敏处理、卡片类型识别等一系列麻烦事。

Stripe Elements就是来解决这个问题的。它是一堆漂亮的、自动校验的、能自适应不同手机屏幕的输入框组件。

代码示例:Stripe Elements前端

这是一个HTML文件,你需要把它放在你的PHP项目的public目录下。

<!DOCTYPE html>
<html>
<head>
    <title>订阅页面</title>
    <!-- 引入Stripe JS库 -->
    <script src="https://js.stripe.com/v3/"></script>
</head>
<body>
    <h1>加入PHP大师班,开启付费之旅</h1>
    <p>价格:¥1999/年</p>

    <form id="payment-form">
        <!-- 这里是Stripe提供的元素容器 -->
        <div id="card-element"></div>
        <div id="card-errors" role="alert"></div>

        <button id="submit" type="submit">订阅</button>
    </form>

    <script>
        // 用你的公钥初始化Stripe
        const stripe = Stripe('pk_test_your_publishable_key_here');
        const elements = stripe.elements();

        // 创建一个Card Element
        const cardElement = elements.create('card');
        // 把它渲染到HTML里
        cardElement.mount('#card-element');

        // 监听表单提交
        const form = document.getElementById('payment-form');
        form.addEventListener('submit', async (event) => {
            event.preventDefault(); // 阻止表单默认提交,我们要用AJAX处理

            // 调用后端PHP接口
            const response = await fetch('/create-subscription.php', {
                method: 'post',
                body: JSON.stringify({
                    email: document.getElementById('email').value, // 你得自己加个邮箱输入框
                    items: [{
                        price: 'price_your_price_id_here', // 填入刚才生成的Price ID
                    }],
                }),
            });

            const result = await response.json();

            if (result.error) {
                // 显示错误信息
                const errorElement = document.getElementById('card-errors');
                errorElement.textContent = result.error.message;
            } else {
                // 支付成功,重定向到支付页面
                stripe.redirectToCheckout({ sessionId: result.sessionId });
            }
        });
    </script>
</body>
</html>

专家点评
看,前端代码是不是很干净?我们没有写任何校验逻辑。这就是Stripe的强大之处。接下来,我们要去写那个create-subscription.php,这是真正的核心战场。


第四部分:PHP后端——接收请求并创建Checkout Session

前端已经把请求扔过来了,我们要在后端创建一个 Checkout Session。这是Stripe里的一个高级概念,它把支付链接、产品信息、客户信息全部打包在一个Session里。

当你创建了Checkout Session后,Stripe会返回一个 sessionId。前端拿到这个ID,调用 stripe.redirectToCheckout() 就能跳转到Stripe官方的结算页面。这比你自己写个支付页面要安全得多,因为所有PCI-DSS合规的工作都由Stripe完成了。

代码示例:create-subscription.php

<?php
require 'vendor/autoload.php';
StripeStripe::setApiKey('sk_test_your_secret_key_here');

header('Content-Type: application/json');

// 1. 获取前端传来的JSON数据
$inputJSON = file_get_contents('php://input');
$input = json_decode($inputJSON, true);

if (!isset($input['email']) || !isset($input['items'])) {
    http_response_code(400);
    echo json_encode(['error' => '缺少必要参数']);
    exit;
}

try {
    // 2. 创建Checkout Session
    // 这个Session会自动创建一个Stripe Customer,并创建一个Subscription
    $checkout_session = StripeCheckoutSession::create([
        'payment_method_types' => ['card'],
        'line_items' => $input['items'],
        'mode' => 'subscription', // 模式:订阅
        'success_url' => 'https://www.yourdomain.com/success?session_id={CHECKOUT_SESSION_ID}',
        'cancel_url' => 'https://www.yourdomain.com/cancel',

        // 3. 设置客户邮箱,Stripe会尝试根据邮箱匹配现有客户
        'customer_email' => $input['email'],

        // 4. 开启Email通知
        'subscription_creation_batching' => true,
    ]);

    // 5. 返回Session ID给前端
    echo json_encode(['sessionId' => $checkout_session->id]);

} catch (Error $e) {
    http_response_code(500);
    echo json_encode(['error' => $e->getMessage()]);
}

专家点评
这里有个重点:mode 设置为 subscription。这意味着当用户完成支付后,Stripe不会直接从用户的卡里扣钱,而是会创建一个订阅。这个订阅有一个 status(状态),默认是 trialing(试用中)或者 active(活跃)。

你可能会问:“我什么时候能拿到钱?”
别急,Stripe每3天会发起一次结算周期,也就是把这一周或一个月里,所有订阅成功的交易的钱,一次性转账到你的银行账户。


第五部分:Webhook回调——那是Stripe在给你发“暗号”

这是今天最关键、也是最容易被忽略的部分。没有Webhook,你的支付系统就是瞎子。

为什么?
当用户在Stripe页面点击“支付”时,那是用户和Stripe的交互。而当你打开数据库,想把“用户已付费”这个状态更新为“是”时,那个时刻PHP代码还没有运行!因为用户还没付完钱呢。

所以,我们需要一个机制,让Stripe在我们不知道的情况下,主动给我们发一条消息(Webhook),通知我们:“嘿,老大,那个叫张三的哥们付完钱了,赶紧去更新数据库!”

1. 设置Webhook Secret

在Stripe Dashboard -> Developers -> Webhooks -> Add Endpoint,选择你的服务器URL。Stripe会给一个 Signing Secret,格式像 whsec_xxxxx。保存好这个Secret,它用于验证消息的来源。

2. 接收并验证Webhook

PHP怎么接收Webhook?很简单,$_POST['payload_json']

但最最重要的是验证签名。这是为了防止黑客伪造请求,说“我付钱了”,结果让你把所有会员都升级成高级版。

<?php
require 'vendor/autoload.php';
StripeStripe::setApiKey('sk_test_your_secret_key_here');

// 你的Webhook Secret
$endpoint_secret = 'whsec_your_webhook_secret_here';

// 获取Stripe发来的原始数据
$payload = @file_get_contents('php://input');
$sig_header = $_SERVER['HTTP_STRIPE_SIGNATURE'];
$event = null;

try {
    // 验证签名
    $event = StripeWebhook::constructEvent(
        $payload, $sig_header, $endpoint_secret
    );
} catch(UnexpectedValueException $e) {
    // 无效的Payload
    http_response_code(400);
    exit;
} catch(StripeExceptionSignatureVerificationException $e) {
    // 无效的签名
    http_response_code(400);
    exit;
}

// 根据事件类型处理不同的业务逻辑
switch ($event->type) {
    case 'checkout.session.completed':
        // 场景1:用户通过Checkout页面支付完成
        $session = $event->data->object;
        handleCheckoutSessionCompleted($session);
        break;

    case 'customer.subscription.created':
        // 场景2:一个新的订阅被创建(通常是Trial期间)
        $subscription = $event->data->object;
        handleSubscriptionCreated($subscription);
        break;

    case 'customer.subscription.updated':
        // 场景3:订阅状态更新(比如从Trial转为Active,或者涨价了)
        $subscription = $event->data->object;
        handleSubscriptionUpdated($subscription);
        break;

    case 'invoice.payment_failed':
        // 场景4:扣费失败(用户卡没钱了)
        $invoice = $event->data->object;
        handleInvoicePaymentFailed($invoice);
        break;

    default:
        // 忽略其他事件
        echo "Received unknown event type: " . $event->type;
}

// 处理Checkout完成逻辑
function handleCheckoutSessionCompleted($session) {
    // 从Session里找到用户ID和订阅ID
    $customerId = $session->customer;
    $subscriptionId = $session->subscription;

    // TODO: 在这里去更新你的数据库
    // 1. 把用户的账号状态改成“已激活”
    // 2. 发送欢迎邮件
    // 3. 开启VIP权限

    echo "Webhook received: checkout.session.completed";
}

// 处理扣费失败逻辑
function handleInvoicePaymentFailed($invoice) {
    // TODO: 给用户发邮件警告:“你的卡没钱了,快充值!”
    // TODO: 记录日志,准备启动催收流程

    echo "Webhook received: invoice.payment_failed";
}

专家点评
注意看 handleCheckoutSessionCompleted 函数。我们拿到了 $session->subscription。这个ID就是通往用户订阅详情的钥匙。去Stripe Dashboard里查一下,你会发现它长得非常像 sub_1234567890


第六部分:订阅的生命周期管理——进阶操作

光知道付钱了还不行,订阅是有生命的。它会经历创建、试用、扣费、延期、延期、延期、延期……直到你取消它。

1. 查询订阅详情

用户付完钱后,你肯定想知道他买了啥。PHP可以通过订阅ID查到所有信息。

<?php
$subscription = StripeSubscription::retrieve('sub_1234567890');

// 查看状态
echo "状态: " . $subscription->status . "<br>"; 
// active, past_due, canceled, trialing, unpaid

// 查看价格ID
echo "价格ID: " . $subscription->items->data[0]->price->id . "<br>";

// 查看当前周期(Next billing date)
echo "下次扣费时间: " . $subscription->current_period_end . "<br>"; // Unix时间戳

2. 暂停/恢复订阅

有时候用户不想取消,只是想暂停一个月。Stripe支持这个操作。

$subscription = StripeSubscription::retrieve('sub_1234567890');

// 暂停
$subscription->cancel_at_period_end = true;
$subscription->save();

// 恢复
$subscription->cancel_at_period_end = false;
$subscription->save();

3. 升级订阅(涨价)

如果你想给老用户涨价,千万别直接删了重建。那样用户体验极差。
你应该创建一个新的Price,然后把用户的订阅替换掉。

// 1. 获取当前订阅
$subscription = StripeSubscription::retrieve('sub_1234567890');

// 2. 添加新的价格项(假设新价格是1999元,旧价格是999元)
$subscription->items->create([
    'price' => 'price_new_higher_price_id',
]);

// 3. 保存,Stripe会自动在下一个周期生效
$subscription->save();

第七部分:常见坑与排雷指南(这很重要,听好了)

做了这么多,代码也跑通了,但总有几个坑会绊倒你。作为专家,我得把这些坑填上。

1. 冷却时间

这是一个新手杀手。
当你创建一个Subscription时,Stripe不会立即扣费。它会先进入 trialing(试用)状态。这个状态通常持续14天。
在14天内,你作为开发者,去查询 $subscription->status,它依然是 trialing。你以为没生效?其实是在等试用期结束。
解决方案:在代码里判断 current_period_startcurrent_period_end 的时间戳,不要只看状态。

2. API的幂等性

Stripe的API设计很优雅,但你要注意。当你重复调用 Subscription::create,如果参数一样,Stripe通常会报错(Duplicate object creation)。你需要用 try-catch 捕获这个错误,不要让程序崩了。

3. 货币单位

再次强调,Stripe里没有“元”这个单位。它只有分。如果你的代码逻辑里涉及到金额计算,一定要转换。

$amountInCents = 199900; // 1999元
$amountInYuan = $amountInCents / 100;

4. Webhook的重试机制

Stripe的Webhook不是发一次就完事的。如果发送到你的服务器时,你的服务器没响应(比如代码报错,或者网线拔了),Stripe会尝试重试。重试次数很多,非常顽固。
解决方案:你的Webhook接口代码必须极其稳定。如果出错了,一定要抛出异常或者返回HTTP 5xx错误,让Stripe知道失败了,否则它会一直重试,直到你的服务器挂掉。

5. 隐私保护

不要在日志里打印用户的卡号(如果Stripe返回了的话)。那是违法的。
不要在前端用 var_dump 调试API返回的JSON。那是给黑客看的。
如果你在本地开发,请务必设置一个代理(比如Charles或者Laravel Tunnel),把API请求转发到Stripe,否则前端会报错“CORS Policy”。


第八部分:Dashboard里的真相

虽然我们写了代码,但有时候Stripe不会告诉你所有事情。你需要去Stripe Dashboard里“看”。

  • Subscriptions(订阅):这里能看到所有订阅的列表。你可以手动取消某个用户的订阅,这叫“后端控制权”。
  • Invoices(发票):这是用户看到的账单。你可以点击“Send”手动发一封账单邮件。
  • Reconciliation(对账):每个月Stripe会给发对账单,你需要把上面的金额和你App里的收入流水对一下。

结语:掌控金钱的艺术

好了,各位同学,今天的讲座到这里就接近尾声了。

我们今天讲了:

  1. 如何用Composer拿到PHP的“核武器”。
  2. 如何定义产品和价格,把抽象的钱变成具体的ID。
  3. 如何用前端优雅地展示支付界面。
  4. 如何用后端创建Checkout Session。
  5. 最重要的,如何用Webhook像接电话一样接住Stripe的通知。

支付系统是Web开发中最核心、也是最敏感的部分。写好代码只是第一步,做好日志、做好监控、做好安全验证才是专家的体现。

当你看到那个 checkout.session.completed 的Webhook触发,你的数据库更新,用户的VIP权限开启,那一刻,你会明白“金钱流转”的魔力。

现在,去写代码吧!记得把API Key藏好,别被黑客薅了羊毛。如果有问题,欢迎在GitHub上提Issue,或者在这个群里吐槽。下课!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注