本文目录导读:

- 目录导读
- 订阅付费的核心业务逻辑与状态机设计
- PHP侧数据库表结构设计
- 支付网关对接:Token化与签名校验
- 订阅周期管理:cron任务与自动化续费
- Webhook回调安全机制:防伪造与幂等性
- 用户权限实时校验:中间件与缓存
- 常见问题问答(FAQ)
PHP订阅付费系统架构设计:从令牌鉴权到Webhook回调的实战指南
目录导读
- 订阅付费的核心业务逻辑与状态机设计
- PHP侧数据库表结构设计(用户、计划、订阅、支付流水)
- 支付网关对接:Stripe/PayPal的Token化与签名校验
- 订阅周期管理:cron任务与到期续费/取消的自动化处理
- Webhook回调安全机制:防止伪造通知与幂等性处理
- 用户权限实时校验:中间件与缓存策略
- 常见问题问答(FAQ)
订阅付费的核心业务逻辑与状态机设计
在PHP项目中,订阅付费并非简单的“支付成功=开通会员”,而是一个包含创建订阅、试用期、活跃期、暂停、逾期、取消、退款等多状态转换的有限状态机。
- 初始状态:
pending(用户发起但未支付) - 激活状态:
active(支付成功或试用开始) - 宽限期:
grace(支付失败后允许重试的1-3天) - 终止状态:
cancelled(用户主动取消,到期后失效)或expired(未续费且已过宽限期)
设计建议:不要只用is_active布尔值,必须存储current_period_start和current_period_end时间戳,且所有状态变更必须写入事件日志表,便于对账与审计。
PHP侧数据库表结构设计
一个健壮的订阅系统至少需要5张核心表:
plans:计划表(名称、价格、周期天数、功能配置JSON字段)subscriptions:订阅表(用户ID、计划ID、状态、当前周期起止时间、取消时间、支付网关订阅ID(如sub_xxx))payments:支付流水表(订单号、金额、货币、网关事务ID、原始回调Payload、状态)webhook_events:Webhook事件接收表(用于幂等去重)user_entitlements:用户权益快照表(可选,便于缓存用户的订阅等级)
CREATE TABLE subscriptions (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT UNSIGNED NOT NULL,
plan_id INT UNSIGNED NOT NULL,
status ENUM('pending','active','grace','cancelled','expired') DEFAULT 'pending',
current_period_start DATETIME NOT NULL,
current_period_end DATETIME NOT NULL,
gateway_subscription_id VARCHAR(64) NULL,
cancel_at_period_end TINYINT(1) DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user_status (user_id, status),
UNIQUE KEY uk_gateway_sub (gateway_subscription_id)
) ENGINE=InnoDB;
支付网关对接:Token化与签名校验
以Stripe为例(PayPal逻辑类似),PHP端绝不能直接发送信用卡号,必须使用Stripe\PaymentIntent创建支付意图,并通过前端Stripe.js收集卡号生成PaymentMethod Token。
服务器端验证代码精髓:
$gateway = new \Stripe\StripeClient($secretKey);
try {
// 使用Token创建订阅
$subscription = $gateway->subscriptions->create([
'customer' => $customerId,
'items' => [['price' => $priceId]],
'payment_behavior' => 'default_incomplete', // 首次支付失败则订阅不激活
'expand' => ['latest_invoice.payment_intent'],
'metadata' => ['user_id' => $userId, 'plan_id' => $planId],
]);
// 将$subscription->id保存到subscriptions.gateway_subscription_id
} catch (\Stripe\Exception\CardException $e) {
// 记录3DS验证需求,前端会收到requires_action
}
关键点:支付成功≠订阅激活,需要等待invoice.payment_succeeded事件,在Webhook中确认后才将本地状态更新为active。
订阅周期管理:cron任务与自动化续费
PHP的cron不适合秒级任务,但适合分钟级扫描,建议每小时运行一次队列消费者:
- 检测即将到期:
current_period_end < now() + 24h且cancel_at_period_end = false,则调用网关API发起续费(创建新invoice)。 - 处理失败支付:若
invoice.payment_failed事件已到达,将订阅状态置为grace,并发送提醒邮件。 - 过期清理:
current_period_end已过期且状态为expired,则撤销本地所有权益缓存,并触发user.entitlements_revoked事件。
伪代码:
// bin/subscription_renewal.php
$dueSubscriptions = Subscription::where('current_period_end', '<=', now()->addDay())
->where('status', 'active')
->where('cancel_at_period_end', false)
->get();
foreach ($dueSubscriptions as $sub) {
$invoice = $gateway->invoices->create(['customer' => $sub->user->gateway_customer_id]);
$invoice->finalizeInvoice();
if ($invoice->status === 'paid') {
$sub->renew(); // 更新period_end + 状态保持active
}
// 否则等待Webhook中的payment_failed事件,不在此直接改状态
}
Webhook回调安全机制:防伪造与幂等性
这是PHP订阅系统最容易出现安全漏洞的地方,攻击者可以伪造成功回调,但绝伪造不出网关的签名。
校验步骤:
$payload = @file_get_contents('php://input');
$sigHeader = $_SERVER['HTTP_STRIPE_SIGNATURE'] ?? '';
$event = \Stripe\Webhook::constructEvent($payload, $sigHeader, $endpointSecret);
// 捕获\Stripe\Exception\SignatureVerificationException则拒绝请求
幂等处理:
$eventId = $event->id; // 如 evt_xxx
// 在webhook_events表中插入唯一event_id,若插入失败(重复)则直接返回200
DB::table('webhook_events')->insertOrIgnore(['event_id' => $eventId, 'payload' => $payload]);
注意:处理checkout.session.completed、invoice.payment_succeeded、invoice.payment_failed、customer.subscription.updated、customer.subscription.deleted这五类事件即可覆盖90%业务,处理完事件后必须返回HTTP 200,否则网关会重试。
用户权限实时校验:中间件与缓存
性能与安全不可偏废,推荐两级缓存策略:
- 第一级:Redis存
user:{id}:subscription_status=active:until:2025-12-31 00:00:00,有效期5分钟。 - 第二级:当用户请求时,中间件先读缓存,若缓存缺失则查数据库。
PHP中间件示例(Laravel风格):
class EnsureSubscribed {
public function handle($request, Closure $next, $planCode = null) {
$user = $request->user();
$cached = Redis::get("sub:{$user->id}:active");
if ($cached && strtotime($cached) > time()) {
return $next($request);
}
$sub = $user->activeSubscription(); // 查询数据库current_period_end > now()
if (!$sub) {
return response()->json(['error' => 'subscription_required'], 403);
}
Redis::setex("sub:{$user->id}:active", 300, $sub->current_period_end);
return $next($request);
}
}
注意:不可仅依赖前端隐藏按钮,所有受保护API路由都必须经过此中间件。
常见问题问答(FAQ)
Q1:用户取消订阅后,为什么我还在数据库看到状态是active?
A:这是正确设计。cancel_at_period_end = true表示“当前周期结束前仍可使用”,而status应保持active直到current_period_end到达,此时应显示给用户“已取消续费”而不是“已停止服务”。
Q2:Webhook回调里修改了订阅状态,但我本地cron也更新,冲突怎么办? A:以Webhook为准,cron只负责发起动作(如创建续费单),状态变更必须由Webhook事件完成,在cron中若发现状态已变,应跳过处理,避免覆盖。
Q3:如何防止用户使用VPN+虚拟卡反复薅试用期?
A:至少做三层校验:1) 注册时记录设备指纹(如HTTP头)与IP;2) 同一张gateway_customer_id对应多个订阅时,判断历史created_at是否间隔小于30天;3) 在支付网关后台设置“同一卡号仅允许一次试用”。
Q4:退款之后如何联动取消订阅?
A:必须监听charge.refunded事件,在事件处理中,找到与该charge关联的subscription_id,调用$gateway->subscriptions->cancel($subId),并更新本地状态为cancelled,且需清理权益缓存。
Q5:如何处理货币汇率变化导致的订阅价格波动?
A:在plans表中存储固定金额与固定货币(如USD 9.99),支付网关自动按本地货币结算,若用户区域汇率变动,不应影响存量订阅,只影响新订阅价格展示,不建议在PHP端做实时汇率转换。
Q6:我的项目不是Laravel,是原生PHP,能实现同样效果吗?
A:可以,原生PHP需自行处理$_SERVER['HTTP_STRIPE_SIGNATURE']与file_get_contents('php://input'),并实现一个简单的数据库抽象层,重点逻辑完全一致,只是框架的Facade替换为普通函数或类。
设计PHP订阅付费系统,核心在于明确状态机、分离支付动作与状态确认、严格校验Webhook签名,不要试图自己实现一套支付卡存储逻辑,那是违反PCI-DSS的雷区,正确做法是:PHP负责业务编排与用户权限判断,支付网关负责资金流安全,将本文中的表结构与中间件代码稍作调整,即可稳定支撑万级用户的订阅业务。