PHP 怎么暂停订阅

wen PHP项目 5

本文目录导读:

PHP 怎么暂停订阅

  1. 引言:暂停订阅远不止"删除一条记录"
  2. 核心概念:订阅状态机(Active / Paused / Cancelled)
  3. 方案一:数据库层面的"软暂停"(推荐)
  4. 方案二:调用支付网关 API(Stripe / PayPal / 支付宝)
  5. 方案三:利用 Webhook 实现异步暂停与通知
  6. 处理边界情况:折扣、试用期、退款与重试
  7. 安全与幂等性:防止重复暂停
  8. 问答环节(FAQ)
  9. 总结与最佳实践建议

** PHP 怎么暂停订阅?一文详解会员续费、Webhook 与支付网关的完整处理逻辑


目录导读(Table of Contents)

  1. 引言:暂停订阅远不止"删除一条记录"
  2. 核心概念:订阅状态机(Active / Paused / Cancelled)
  3. 数据库层面的"软暂停"(推荐)
  4. 调用支付网关 API(Stripe / PayPal / 支付宝)
  5. 利用 Webhook 实现异步暂停与通知
  6. 处理边界情况:折扣、试用期、退款与重试
  7. 安全与幂等性:防止重复暂停
  8. 问答环节(FAQ)
  9. 总结与最佳实践建议

引言:暂停订阅远不止"删除一条记录"

很多 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 是无法完成的,建议严格按照以下步骤:

  1. 业务校验:确认用户是否可以暂停(剩余暂停次数)。
  2. 网关API操作:调用 Stripe / PayPal 的暂停接口,锁定扣费。
  3. 本地DB更新:标记状态为 paused,并保存暂停时间戳。
  4. 异步补偿:通过 Webhook 监听网关侧的异常行为(如用户自行取消),确保双向同步。
  5. 恢复流程:提供明确的恢复入口,并处理恢复时的支付方式校验。

别忘了写完善的单元测试,模拟网关故障与 Webhook 重试场景。只要保证数据状态一致性和API操作幂等性,你的订阅系统就能安全迈向生产环境。


抱歉,评论功能暂时关闭!