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;
这里要注意两点:
- 前端只能提交功能代码和数量,不能提交最终金额;
- 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 重放等场景。
- 如果需要处理增值税、销售税或电子发票,应根据实际经营地区咨询财税专业人士。
备注:内容仅供参考。
