PAYATHON 2026

PHP Authentication 与 SaaS 月度计费如何实现?

支付阿杰

结论

PHP Authentication 只用来确认“用户是谁”,不能直接处理 SaaS 月度计费。比较稳妥的做法是:

  • 用成熟的 PHP 框架处理注册、登录和权限;
  • 用 Stripe Billing、Paddle 等支付平台管理订阅、自动续费和账单;
  • 把各项功能设计成固定套餐或附加项;
  • 通过支付平台的 Webhook 同步订阅状态;
  • 后端根据本地保存的订阅记录控制功能访问。

不要自行保存银行卡信息,也不建议从头开发周期扣款系统。下文以 Stripe 为例。具体 API 字段可能随 Stripe PHP SDK 版本变化,接入时请以当前使用版本的官方文档为准。

推荐的数据模型

一种简单的定价方式是“基础套餐 + 若干附加项”:

月费 = 基础套餐价格 + Σ(附加项单价 × 数量)

例如:

基础版:¥99/月
团队协作:¥30/月
高级报表:¥50/月
额外成员:¥10/人/月

数据库至少要保存用户与支付平台账户之间的对应关系:

CREATE TABLE subscriptions (
    id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    user_id BIGINT UNSIGNED NOT NULL,
    provider_customer_id VARCHAR(255) NOT NULL,
    provider_subscription_id VARCHAR(255) DEFAULT NULL,
    status VARCHAR(50) NOT NULL,
    current_period_end DATETIME DEFAULT NULL,
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL,
    UNIQUE KEY uniq_user_id (user_id),
    UNIQUE KEY uniq_subscription_id (provider_subscription_id)
);

功能权限可以放在单独的表中:

CREATE TABLE subscription_features (
    subscription_id BIGINT UNSIGNED NOT NULL,
    feature_code VARCHAR(100) NOT NULL,
    quantity INT UNSIGNED NOT NULL DEFAULT 1,
    PRIMARY KEY (subscription_id, feature_code)
);

金额和支付状态要以支付平台返回的数据为准,不能相信浏览器提交的价格。

认证与计费如何连接

用户登录后,PHP Authentication 应提供一个稳定的内部用户 ID,例如:

$userId = $_SESSION['user_id'] ?? null;

if ($userId === null) {
    http_response_code(401);
    exit('Authentication required');
}

生产环境中,最好使用 Laravel、Symfony 等框架自带的认证组件,不要自行编写密码校验逻辑。如果确实要直接处理密码,至少应使用 PHP 原生接口:

$hash = password_hash($password, PASSWORD_DEFAULT);

if (!password_verify($submittedPassword, $hash)) {
    http_response_code(401);
    exit('Invalid credentials');
}

认证成功后,通过 user_id 查询对应的 provider_customer_id 和订阅状态。认证系统不负责实际扣款,只负责将当前用户与支付平台中的客户关联起来。

创建订阅结账页面

先安装 Stripe PHP SDK:

composer require stripe/stripe-php

在 Stripe 后台分别为基础套餐和各项附加功能创建 recurring Price,再把对应的 Price ID 保存到服务端配置中。

下面的示例会根据用户选择创建 Stripe Checkout Session:

<?php

require __DIR__ . '/vendor/autoload.php';

use Stripe\StripeClient;

session_start();

$userId = $_SESSION['user_id'] ?? null;

if ($userId === null) {
    http_response_code(401);
    exit('Authentication required');
}

$stripe = new StripeClient($_ENV['STRIPE_SECRET_KEY']);

$allowedPrices = [
    'base'            => $_ENV['STRIPE_PRICE_BASE'],
    'team'            => $_ENV['STRIPE_PRICE_TEAM'],
    'advanced_report' => $_ENV['STRIPE_PRICE_ADVANCED_REPORT'],
    'extra_member'    => $_ENV['STRIPE_PRICE_EXTRA_MEMBER'],
];

$selectedFeatures = $_POST['features'] ?? [];
$extraMembers = max(0, (int) ($_POST['extra_members'] ?? 0));

$lineItems = [
    [
        'price' => $allowedPrices['base'],
        'quantity' => 1,
    ],
];

foreach (['team', 'advanced_report'] as $feature) {
    if (in_array($feature, $selectedFeatures, true)) {
        $lineItems[] = [
            'price' => $allowedPrices[$feature],
            'quantity' => 1,
        ];
    }
}

if ($extraMembers > 0) {
    $lineItems[] = [
        'price' => $allowedPrices['extra_member'],
        'quantity' => $extraMembers,
    ];
}

$session = $stripe->checkout->sessions->create([
    'mode' => 'subscription',
    'line_items' => $lineItems,
    'success_url' => 'https://example.com/billing/success?session_id={CHECKOUT_SESSION_ID}',
    'cancel_url' => 'https://example.com/billing',
    'client_reference_id' => (string) $userId,
    'metadata' => [
        'user_id' => (string) $userId,
    ],
]);

header('Location: ' . $session->url, true, 303);
exit;

这里要注意两点:

  1. 前端只能提交功能代码和数量,不能提交最终金额;
  2. Price ID 必须从服务端白名单中读取,不能直接使用用户传入的 Price ID。

如果数据库里已经保存了 Stripe Customer ID,可以在创建 Session 时传入 customer,避免为同一用户重复创建支付客户。

增加或减少功能

订阅建立后,功能变更就是修改订阅中的 subscription item:

  • 增加功能:添加对应 Price;
  • 取消功能:删除对应 subscription item;
  • 修改席位数:更新该 item 的 quantity;
  • 是否立即补差价:由 proration 策略决定。

示意代码如下:

$stripe->subscriptions->update(
    $subscriptionId,
    [
        'items' => [
            [
                'id' => $extraMemberSubscriptionItemId,
                'quantity' => $newQuantity,
            ],
        ],
        'proration_behavior' => 'create_prorations',
    ]
);

$extraMemberSubscriptionItemId 是订阅项目 ID,不是 Price ID。更新前应先读取当前订阅,找到对应的项目。

create_prorations 通常表示按计费周期的剩余时间计算差额。如果业务要求变更到下个月才生效,就需要使用延迟变更、订阅计划,或者自行记录待生效配置。不同 Stripe API 版本的订阅周期字段和行为可能不同,因此需要结合实际账户配置进行测试。

使用 Webhook 同步状态

不能只依赖支付成功后的跳转页面,因为用户付款后可能直接关闭浏览器。订阅状态必须通过 Webhook 更新。

常见事件包括:

checkout.session.completed
customer.subscription.created
customer.subscription.updated
customer.subscription.deleted
invoice.paid
invoice.payment_failed

Webhook 入口必须验证签名:

<?php

require __DIR__ . '/vendor/autoload.php';

use Stripe\Webhook;
use Stripe\Exception\SignatureVerificationException;

$payload = file_get_contents('php://input');
$signature = $_SERVER['HTTP_STRIPE_SIGNATURE'] ?? '';
$secret = $_ENV['STRIPE_WEBHOOK_SECRET'];

try {
    $event = Webhook::constructEvent($payload, $signature, $secret);
} catch (UnexpectedValueException | SignatureVerificationException $e) {
    http_response_code(400);
    exit('Invalid webhook');
}

switch ($event->type) {
    case 'customer.subscription.created':
    case 'customer.subscription.updated':
    case 'customer.subscription.deleted':
        $subscription = $event->data->object;

        // 根据 $subscription->id 更新本地订阅状态。
        // 数据库写入应使用参数化查询,并保证重复处理不会产生副作用。
        break;

    case 'invoice.paid':
        $invoice = $event->data->object;

        // 记录付款成功。是否开放功能仍应结合订阅状态判断。
        break;

    case 'invoice.payment_failed':
        $invoice = $event->data->object;

        // 记录付款失败,并按业务规则通知用户或限制服务。
        break;
}

http_response_code(200);

Webhook 可能重复发送,也可能因网络问题延迟到达,所以处理逻辑必须具备幂等性。常见做法是保存 event.id,遇到已经处理过的事件时直接返回成功。

根据订阅控制功能

功能权限必须由后端校验,不能只在前端隐藏按钮:

function requireFeature(int $userId, string $featureCode): void
{
    $subscription = findActiveSubscription($userId);

    if ($subscription === null) {
        http_response_code(403);
        exit('Active subscription required');
    }

    if (!subscriptionHasFeature($subscription['id'], $featureCode)) {
        http_response_code(403);
        exit('Feature not included in subscription');
    }
}

调用方式:

requireFeature($userId, 'advanced_report');

generateAdvancedReport();

还要明确哪些订阅状态可以继续使用服务。例如:

  • active:正常开放;
  • trialing:试用期开放;
  • past_due:是否给予宽限期由业务规则决定;
  • canceled、unpaid:通常停止访问付费功能。

不要只根据状态字符串的名称判断支付结果。应结合支付平台返回的实际订阅对象和自身业务规则,建立明确的状态映射。

更省事的选择

如果产品早期只有两三个固定组合,直接建立几个套餐通常比让用户任意勾选功能更简单:

基础版
专业版
企业版

这样能减少价格组合、升级降级、退款和权限同步带来的复杂度。只有席位数这类自然会变化的项目,才适合使用 quantity 计费。

如果使用 Laravel,可以考虑用 Laravel Cashier 封装 Stripe 订阅操作。不过,它仍然不能代替产品定价设计、Webhook 处理和后端权限校验。

注意事项

  • 密钥只能保存在环境变量或密钥管理服务中,不能写入代码仓库。
  • 不要存储银行卡号、CVC 等敏感支付数据。
  • 创建结账会话和修改订阅的接口都必须要求登录,并启用 CSRF 防护。
  • 限制数量字段的取值范围,防止负数、超大值和重复提交。
  • 计算金额时使用最小货币单位的整数,避免浮点数误差。
  • 明确升级、降级、取消、退款、税费和宽限期规则。
  • 保存支付平台 Customer、Subscription、Subscription Item 和 Price 的标识,不能只保存套餐名称。
  • 在测试环境中覆盖首次订阅、续费失败、功能增减、取消、退款和 Webhook 重放等场景。
  • 如果需要处理增值税、销售税或电子发票,应根据实际经营地区咨询财税专业人士。

备注:内容仅供参考。