PHP 订阅付费设计

wen PHP项目 2

本文目录导读:

PHP 订阅付费设计

  1. 目录导读
  2. 订阅付费的核心业务逻辑与状态机设计
  3. PHP侧数据库表结构设计
  4. 支付网关对接:Token化与签名校验
  5. 订阅周期管理:cron任务与自动化续费
  6. Webhook回调安全机制:防伪造与幂等性
  7. 用户权限实时校验:中间件与缓存
  8. 常见问题问答(FAQ)

PHP订阅付费系统架构设计:从令牌鉴权到Webhook回调的实战指南

目录导读

  1. 订阅付费的核心业务逻辑与状态机设计
  2. PHP侧数据库表结构设计(用户、计划、订阅、支付流水)
  3. 支付网关对接:Stripe/PayPal的Token化与签名校验
  4. 订阅周期管理:cron任务与到期续费/取消的自动化处理
  5. Webhook回调安全机制:防止伪造通知与幂等性处理
  6. 用户权限实时校验:中间件与缓存策略
  7. 常见问题问答(FAQ)

订阅付费的核心业务逻辑与状态机设计

在PHP项目中,订阅付费并非简单的“支付成功=开通会员”,而是一个包含创建订阅试用期活跃期暂停逾期取消退款等多状态转换的有限状态机。

  • 初始状态pending(用户发起但未支付)
  • 激活状态active(支付成功或试用开始)
  • 宽限期grace(支付失败后允许重试的1-3天)
  • 终止状态cancelled(用户主动取消,到期后失效)或expired(未续费且已过宽限期)

设计建议:不要只用is_active布尔值,必须存储current_period_startcurrent_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不适合秒级任务,但适合分钟级扫描,建议每小时运行一次队列消费者:

  1. 检测即将到期current_period_end < now() + 24hcancel_at_period_end = false,则调用网关API发起续费(创建新invoice)。
  2. 处理失败支付:若invoice.payment_failed事件已到达,将订阅状态置为grace,并发送提醒邮件。
  3. 过期清理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.completedinvoice.payment_succeededinvoice.payment_failedcustomer.subscription.updatedcustomer.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负责业务编排与用户权限判断,支付网关负责资金流安全,将本文中的表结构与中间件代码稍作调整,即可稳定支撑万级用户的订阅业务。

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