本文目录导读:

- 引言:暂停订阅远不止"删除一条记录"
- 核心概念:订阅状态机(Active / Paused / Cancelled)
- 方案一:数据库层面的"软暂停"(推荐)
- 方案二:调用支付网关 API(Stripe / PayPal / 支付宝)
- 方案三:利用 Webhook 实现异步暂停与通知
- 处理边界情况:折扣、试用期、退款与重试
- 安全与幂等性:防止重复暂停
- 问答环节(FAQ)
- 总结与最佳实践建议
** PHP 怎么暂停订阅?一文详解会员续费、Webhook 与支付网关的完整处理逻辑
目录导读(Table of Contents)
- 引言:暂停订阅远不止"删除一条记录"
- 核心概念:订阅状态机(Active / Paused / Cancelled)
- 数据库层面的"软暂停"(推荐)
- 调用支付网关 API(Stripe / PayPal / 支付宝)
- 利用 Webhook 实现异步暂停与通知
- 处理边界情况:折扣、试用期、退款与重试
- 安全与幂等性:防止重复暂停
- 问答环节(FAQ)
- 总结与最佳实践建议
引言:暂停订阅远不止"删除一条记录"
很多 PHP 开发者初次接触"暂停订阅"功能时,会以为只是执行一条 UPDATE users SET status = 'paused' 这样简单的 SQL,在现代 SaaS(软件即服务)系统中,特别是当集成了 Stripe、PayPal、Paddle 或国内支付宝周期扣款时,暂停订阅涉及三端同步:你的数据库、支付平台的订阅对象、用户的实际体验,如果处理不当,会出现"用户以为暂停了,但下个月依然被扣费"的严重客诉。
本篇文章将基于 PHP 8.x + Laravel/原生框架,深度剖析如何在代码层面稳健地实现"暂停订阅",并涵盖 Webhook 回调、幂等性和异常补偿机制,文章参考了 Stripe 官方文档、PayPal Billing Agreements 规范以及 Stack Overflow 上的高频问题,进行了去伪存真和总结。
核心概念:订阅状态机(Active / Paused / Cancelled)
在动手写代码前,我们必须明确状态定义。暂停(Pause) 与 取消(Cancel) 有本质区别:
- Active(活动):订阅正常,下一个计费日会扣款。
- Paused(暂停):服务停止,但不删除订阅关系,通常暂停期间不扣费,且暂停时间有上限(如90天)。
- Cancelled(取消):到期后不续费,或立即失效,订阅关系终结。
关键一问: 暂停期间,用户的数据是保留还是冻结?如果是云盘类应用,暂停通常保留数据可读但不可写,如果是流媒体,暂停则禁止登录,这个逻辑必须在 UserSubscription 模型中用 status 字段标识。
方案一:数据库层面的"软暂停"(推荐)
如果你们是自研支付系统,或者使用了简单的代币扣费,那么最稳妥的方式是标记暂停,而非删除。
伪代码逻辑:
// PauseSubscription.php
public function pause(User $user, string $subscriptionId): void
{
$subscription = $user->subscriptions()->findOrFail($subscriptionId);
// 1. 业务逻辑校验:是否允许暂停(例如已暂停过3次则不允许)
abort_if($subscription->paused_count >= 3, 403, '暂停次数已达上限');
// 2. 记录当前周期截止时间,以便恢复时延长
$subscription->previous_end_at = $subscription->current_period_end;
$subscription->status = 'paused';
$subscription->paused_at = now();
$subscription->paused_count++;
$subscription->save();
// 3. 触发用户权限变更(例如移除VIP角色)
$user->revokeRole('premium');
// 4. 记录操作日志
ActivityLog::log($user->id, 'subscription_paused', $subscriptionId);
}
注意: 这里并没有直接调用支付网关,只是先暂停了服务,而真正的扣款停止,需要由支付网关的API来配合,否则下一个周期账单会照常发出。
方案二:调用支付网关 API(Stripe / PayPal / 支付宝)
这是整个流程中最核心的部分。 如果你的订阅是通过 Stripe 创建的,那么你需要告诉 Stripe "暂停这个订阅计划",在 Stripe 中,有专门的方法:$stripe->subscriptions->update($subId, ['pause_collection' => ['behavior' => 'mark_uncollectible']]);而在 PayPal 中,则是 SUSPEND 操作。
PHP 实现示例(Stripe):
use Stripe\StripeClient;
$stripe = new StripeClient(config('services.stripe.secret'));
try {
// 关键参数:pause_collection 表示暂停收费
$stripe->subscriptions->update($stripeSubscriptionId, [
'pause_collection' => [
'behavior' => 'void', // 或者 'mark_uncollectible' — 取决于你要求未付账单如何处理
// 'resumes_at' => strtotime('+30 days') // 可选:30天后自动恢复
],
]);
// 数据库同步更新
DB::table('subscriptions')
->where('stripe_id', $stripeSubscriptionId)
->update(['status' => 'paused', 'paused_at' => now()]);
} catch (\Stripe\Exception\ApiErrorException $e) {
// 记录异常,并回滚数据库状态
Log::error('Stripe pause failed: ' . $e->getMessage());
// 注意:一定要回滚Code,因为你先改了数据库再调API出错的话,要还原。
}
关键细节: 调用 pause_collection 后,Stripe 将不再生成新的 invoice,但如果你有未结清的余额(比如上个月的欠款),Stripe 可能仍会继续尝试收款,这一点需要单独处理。
方案三:利用 Webhook 实现异步暂停与通知
支付网关的行为往往会因异步事件而改变(例如用户信用卡过期导致扣款失败),您不能仅仅依赖主动调用API。正确的架构是:主动API调用 + 被动Webhook监听。
在 Laravel 中处理 Stripe Webhook:
// UserSubscriptionWebhookController.php
public function handle(Request $request)
{
$event = $request->all();
// 验证签名(省略)
switch ($event['type']) {
case 'customer.subscription.paused':
// 用户在 Stripe 后台手动暂停了订阅
$subscriptionId = $event['data']['object']['id'];
$this->markAsPaused($subscriptionId);
break;
case 'customer.subscription.updated':
// 状态变更(可能从 active 变为 past_due 或者 paused)
$sub = $event['data']['object'];
if ($sub['status'] === 'paused') {
$this->markAsPaused($sub['id']);
}
break;
case 'invoice.payment_failed':
// 扣款失败后,我们通常会自动暂停订阅(服务降级)
$subId = $event['data']['object']['subscription'];
$this->safetyPause($subId);
break;
}
return response('Webhook received', 200);
}
为什么要用 Webhook? 因为用户可能在 PayPal 的 App 里直接点击了"取消自动付款",这个操作不会通知你的 PHP 服务器,只有 Webhook 能告诉你:"喂,用户的订阅已经被第三方平台暂停了,你的库也得更新。"
处理边界情况:折扣、试用期、退款与重试
在编写"暂停"代码时,以下情况是隐形的雷区:
- 试用期暂停: 如果用户处于
trialing状态,一般不允许暂停,应直接取消。 - 折扣清零: 如果暂停期间,用户的优惠券过期,恢复后应重新计算费用,最好的做法是暂停时冻结当前周期价格。
- 退款冲突: 暂停时如果用户有未付账单,应先行处理逾期账单,否则恢复订阅时可能造成叠加账单。
- 幂等性: 请求重复提交(Webhook 重试)会导致状态错乱,必须在暂停方法入口加锁或
where('status', 'active')条件判断。
// 幂等判断——防止重复暂停
$affected = DB::table('subscriptions')
->where('id', $subId)
->where('status', 'active') // 只有 active 状态才能转成 paused
->update(['status' => 'paused']);
if (!$affected) {
// 说明已经被暂停过了,直接返回
return;
}
安全与幂等性:防止重复暂停
安全拓展: 暂停操作必须经过二次验证,建议采用 SignedURL 或者 FormRequest 验证权限,确保只有订阅所有者或管理员能操作,尽量为暂停操作添加 API Rate Limiting,防止恶意刷接口。
问答环节(FAQ)
Q1:PHP 怎么暂停订阅后,用户还能不能登录?
答: 这属于业务层逻辑,暂停后,可以在用户登录中间件(Middleware)中查询 subscription->status,若为 paused,则返回 Account suspended 页面,但注意,一定要允许用户访问"恢复订阅"或"取消订阅"的入口。
Q2:如果支付网关 API 调用失败,但数据库我已经改了暂停,怎么办? 答: 这是典型的事务一致性难题。必须先调用网关API成功,再更新本地数据库。 如果数据库更新失败,则必须主动调用网关的"恢复"接口(resume),最终记录日志人工处理,只有这样才能保证网关不会继续扣款。
Q3:暂停了订阅,下个月还会扣费吗?
答: 对于 Stripe,使用 pause_collection 后会停止自动扣费,但会默认保留支付方式,只要你不恢复订阅,就不会扣费,对于 PayPal,必须调用 SUSPEND 才会完全停止。不要只改数据库,一定要操作支付网关。
Q4:暂停之后如何让用户恢复订阅?
答: 调用对应网关的 resume 方法(Stripe 中设置为 pause_collection => '' 并更新 resumes_at 为 null),然后将数据库的 status 改回 active,并重新计算 current_period_end(通常是从暂停前的周期自动顺延)。
Q5:如果暂停期间用户删除了支付方式,恢复时会怎样?
答: 恢复时如果抓取不到默认支付方式,网关会返回错误,此时你需要在恢复前检测 default_payment_method,如果为空,提示用户重新绑定银行卡。
总结与最佳实践建议
PHP 暂停订阅是一个集成业务与支付网关状态的复合操作。 单独依靠一段 SQL 是无法完成的,建议严格按照以下步骤:
- 业务校验:确认用户是否可以暂停(剩余暂停次数)。
- 网关API操作:调用 Stripe / PayPal 的暂停接口,锁定扣费。
- 本地DB更新:标记状态为
paused,并保存暂停时间戳。 - 异步补偿:通过 Webhook 监听网关侧的异常行为(如用户自行取消),确保双向同步。
- 恢复流程:提供明确的恢复入口,并处理恢复时的支付方式校验。
别忘了写完善的单元测试,模拟网关故障与 Webhook 重试场景。只要保证数据状态一致性和API操作幂等性,你的订阅系统就能安全迈向生产环境。