PHP项目Cashier与订阅计划

wen PHP项目 1

本文目录导读:

PHP项目Cashier与订阅计划

  1. Laravel Cashier 简介
  2. 数据库迁移设计
  3. 订阅计划模型
  4. 订阅服务类
  5. Webhook 处理
  6. 前端 Vue 组件示例
  7. API 路由
  8. 订阅控制器
  9. 计划控制器
  10. 数据库种子数据
  11. 环境配置

我来详细介绍PHP项目中实现收银台(Cashier)和订阅计划的方案。

Laravel Cashier 简介

Laravel Cashier 是处理订阅计费的官方包,支持 Stripe 和 Paddle。

安装 Cashier (Stripe)

composer require laravel/cashier
php artisan vendor:publish --tag=cashier-migrations
php artisan migrate

数据库迁移设计

// subscriptions 表
Schema::create('subscriptions', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id');
    $table->string('name'); // 订阅名称
    $table->string('stripe_id')->unique();
    $table->string('stripe_status');
    $table->string('stripe_price')->nullable();
    $table->integer('quantity')->nullable();
    $table->timestamp('trial_ends_at')->nullable();
    $table->timestamp('ends_at')->nullable();
    $table->timestamps();
});
// subscription_items 表
Schema::create('subscription_items', function (Blueprint $table) {
    $table->id();
    $table->foreignId('subscription_id');
    $table->string('stripe_id')->unique();
    $table->string('stripe_product');
    $table->string('stripe_price');
    $table->integer('quantity')->nullable();
    $table->timestamps();
});

订阅计划模型

<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Plan extends Model
{
    protected $fillable = [
        'name',
        'slug',
        'stripe_price_id',
        'description',
        'price',
        'currency',
        'interval',
        'interval_count',
        'trial_period_days',
        'features',
        'sort_order',
        'is_active',
        'is_popular'
    ];
    protected $casts = [
        'features' => 'array',
        'is_active' => 'boolean',
        'is_popular' => 'boolean',
        'price' => 'decimal:2'
    ];
    // 价格格式化
    public function formattedPrice(): string
    {
        return $this->currency . ' ' . number_format($this->price / 100, 2);
    }
    // 周期描述
    public function intervalDescription(): string
    {
        $intervals = [
            'day' => '天',
            'week' => '周',
            'month' => '月',
            'year' => '年'
        ];
        $intervalName = $intervals[$this->interval] ?? $this->interval;
        if ($this->interval_count > 1) {
            return "每{$this->interval_count}{$intervalName}";
        }
        return "每月";
    }
    // 获取推荐计划
    public function scopePopular($query)
    {
        return $query->where('is_popular', true);
    }
    // 获取可用计划
    public function scopeActive($query)
    {
        return $query->where('is_active', true)->orderBy('sort_order');
    }
}

订阅服务类

<?php
namespace App\Services;
use App\Models\Plan;
use App\Models\User;
use Stripe\Stripe;
use Stripe\Checkout\Session;
use Stripe\Customer;
use Stripe\Subscription;
class SubscriptionService
{
    public function __construct()
    {
        Stripe::setApiKey(config('cashier.secret'));
    }
    // 创建结账会话
    public function createCheckoutSession(User $user, Plan $plan): Session
    {
        return Session::create([
            'customer' => $this->getOrCreateCustomer($user),
            'mode' => 'subscription',
            'line_items' => [[
                'price' => $plan->stripe_price_id,
                'quantity' => 1,
            ]],
            'success_url' => route('subscription.success', [], true),
            'cancel_url' => route('subscription.cancel', [], true),
            'metadata' => [
                'user_id' => $user->id,
                'plan_id' => $plan->id
            ]
        ]);
    }
    // 获取或创建 Stripe 客户
    private function getOrCreateCustomer(User $user): string
    {
        if ($user->stripe_id) {
            return $user->stripe_id;
        }
        $customer = Customer::create([
            'email' => $user->email,
            'name' => $user->name,
            'metadata' => [
                'user_id' => $user->id
            ]
        ]);
        $user->stripe_id = $customer->id;
        $user->save();
        return $customer->id;
    }
    // 升级/降级订阅
    public function swapSubscription(User $user, Plan $newPlan): bool
    {
        if (!$user->subscribed('default')) {
            return false;
        }
        $subscription = $user->subscription('default');
        try {
            $subscription->swap($newPlan->stripe_price_id);
            return true;
        } catch (\Exception $e) {
            report($e);
            return false;
        }
    }
    // 取消订阅
    public function cancelSubscription(User $user): bool
    {
        if (!$user->subscribed('default')) {
            return false;
        }
        try {
            $subscription = $user->subscription('default');
            // 立即取消
            // $subscription->cancelNow();
            // 周期结束后取消
            $subscription->cancel();
            return true;
        } catch (\Exception $e) {
            report($e);
            return false;
        }
    }
    // 恢复订阅
    public function resumeSubscription(User $user): bool
    {
        if (!$user->subscription('default')->cancelled()) {
            return false;
        }
        try {
            $user->subscription('default')->resume();
            return true;
        } catch (\Exception $e) {
            report($e);
            return false;
        }
    }
    // 获取订阅状态
    public function getSubscriptionStatus(User $user): array
    {
        if (!$user->subscribed('default')) {
            return ['status' => 'none'];
        }
        $subscription = $user->subscription('default');
        return [
            'status' => $subscription->stripe_status,
            'plan_id' => $subscription->stripe_price,
            'ends_at' => $subscription->ends_at,
            'trial_ends_at' => $subscription->trial_ends_at,
            'can_cancel' => $subscription->active(),
            'can_resume' => $subscription->cancelled() && !$subscription->ended(),
            'on_grace_period' => $subscription->onGracePeriod()
        ];
    }
}

Webhook 处理

<?php
namespace App\Http\Controllers;
use Laravel\Cashier\Http\Controllers\WebhookController as CashierController;
class StripeWebhookController extends CashierController
{
    // 支付成功
    protected function handleInvoicePaymentSucceeded($payload)
    {
        $subscriptionId = $payload['data']['object']['subscription'];
        $userId = $payload['data']['object']['metadata']['user_id'] ?? null;
        if ($userId) {
            $user = User::find($userId);
            // 更新用户角色或权限
            $user->assignRole('subscriber');
            // 记录日志
            Log::info('Payment succeeded for user', [
                'user_id' => $userId,
                'subscription_id' => $subscriptionId
            ]);
        }
        return response()->json(['status' => 'success']);
    }
    // 支付失败
    protected function handleInvoicePaymentFailed($payload)
    {
        $userId = $payload['data']['object']['metadata']['user_id'] ?? null;
        if ($userId) {
            $user = User::find($userId);
            // 发送通知
            $user->notify(new PaymentFailed());
            Log::warning('Payment failed for user', [
                'user_id' => $userId
            ]);
        }
        return response()->json(['status' => 'success']);
    }
    // 订阅取消
    protected function handleCustomerSubscriptionDeleted($payload)
    {
        $userId = $payload['data']['object']['metadata']['user_id'] ?? null;
        if ($userId) {
            $user = User::find($userId);
            // 更新权限
            $user->removeRole('subscriber');
            Log::info('Subscription cancelled for user', [
                'user_id' => $userId
            ]);
        }
        return response()->json(['status' => 'success']);
    }
}

前端 Vue 组件示例

<template>
  <div class="pricing-container">
    <div class="text-center mb-8">
      <h2 class="text-3xl font-bold">选择你的计划</h2>
      <p class="mt-4 text-gray-600">随时可以升级或降级</p>
    </div>
    <div class="grid grid-cols-1 md:grid-cols-3 gap-8">
      <div 
        v-for="plan in plans" 
        :key="plan.id"
        :class="['plan-card', { 'popular': plan.is_popular }]"
      >
        <!-- 推荐标签 -->
        <div v-if="plan.is_popular" class="popular-badge">
          最受欢迎
        </div>
        <!-- 计划信息 -->
        <div class="plan-header">
          <h3 class="text-xl font-bold">{{ plan.name }}</h3>
          <p class="text-gray-600 mt-2">{{ plan.description }}</p>
        </div>
        <!-- 价格 -->
        <div class="price-section">
          <span class="price">${{ plan.price / 100 }}</span>
          <span class="interval">/{{ plan.interval }}</span>
        </div>
        <!-- 功能列表 -->
        <ul class="features-list">
          <li v-for="feature in plan.features" :key="feature" class="feature-item">
            <CheckIcon class="w-5 h-5 text-green-500" />
            {{ feature }}
          </li>
        </ul>
        <!-- 操作按钮 -->
        <button
          @click="handleSubscribe(plan)"
          class="subscribe-btn"
        >
          {{ userSubscribed ? '切换到此计划' : '立即订阅' }}
        </button>
      </div>
    </div>
    <!-- 当前订阅信息 -->
    <div v-if="currentSubscription" class="current-subscription">
      <h3 class="text-lg font-semibold">当前订阅</h3>
      <p>计划: {{ currentSubscription.plan_name }}</p>
      <p>状态: {{ getStatusText(currentSubscription.status) }}</p>
      <p v-if="currentSubscription.ends_at">
        将在: {{ currentSubscription.ends_at }} 结束
      </p>
      <button 
        v-if="currentSubscription.can_cancel"
        @click="handleCancel"
        class="cancel-btn"
      >
        取消订阅
      </button>
      <button 
        v-if="currentSubscription.can_resume"
        @click="handleResume"
        class="resume-btn"
      >
        恢复订阅
      </button>
    </div>
  </div>
</template>
<script>
import { ref, onMounted } from 'vue'
import { CheckIcon } from '@heroicons/vue/solid'
export default {
  components: {
    CheckIcon
  },
  setup() {
    const plans = ref([])
    const currentSubscription = ref(null)
    const userSubscribed = ref(false)
    // 获取付款链接
    const getCheckoutUrl = async () => {
      try {
        const response = await axios.post('/api/subscription/checkout', {
          plan_id: planId
        })
        return response.data.url
      } catch (error) {
        console.error('Failed to get checkout URL:', error)
        throw error
      }
    }
    // 订阅处理
    const handleSubscribe = async (plan) => {
      try {
        // 检查是否已订阅
        const subscriptionStatus = await checkSubscription()
        if (subscriptionStatus.subscribed) {
          // 已订阅则进行计划切换
          await swapSubscription(plan.id)
          alert('计划切换成功!')
        } else {
          // 未订阅则创建结账会话
          const checkoutUrl = await getCheckoutUrl(plan.id)
          window.location.href = checkoutUrl
        }
      } catch (error) {
        console.error('Subscription error:', error)
        alert('操作失败,请重试')
      }
    }
    // 取消订阅
    const handleCancel = async () => {
      if (!confirm('确定要取消订阅吗?')) return
      try {
        await axios.post('/api/subscription/cancel')
        alert('订阅已取消')
        await fetchStatus()
      } catch (error) {
        console.error('Cancel error:', error)
      }
    }
    // 恢复订阅
    const handleResume = async () => {
      try {
        await axios.post('/api/subscription/resume')
        alert('订阅已恢复')
        await fetchStatus()
      } catch (error) {
        console.error('Resume error:', error)
      }
    }
    // 获取状态文本
    const getStatusText = (status) => {
      const statusMap = {
        'active': '活跃',
        'canceled': '已取消',
        'past_due': '逾期',
        'trialing': '试用中',
        'incomplete': '未完成'
      }
      return statusMap[status] || status
    }
    // 初始化
    onMounted(async () => {
      try {
        const [plansResponse, subscriptionResponse] = await Promise.all([
          axios.get('/api/plans'),
          axios.get('/api/subscription/status')
        ])
        plans.value = plansResponse.data.data
        currentSubscription.value = subscriptionResponse.data
        userSubscribed.value = subscriptionResponse.data.status !== 'none'
      } catch (error) {
        console.error('Initialization error:', error)
      }
    })
    return {
      plans,
      currentSubscription,
      userSubscribed,
      handleSubscribe,
      handleCancel,
      handleResume,
      getStatusText
    }
  }
}
</script>
<style scoped>
.pricing-container {
  @apply max-w-7xl mx-auto py-12 px-4;
}
.plan-card {
  @apply relative bg-white rounded-lg shadow-lg p-8 border border-gray-200;
}
.plan-card.popular {
  @apply border-blue-500 shadow-xl;
  transform: scale(1.05);
}
.popular-badge {
  @apply absolute -top-3 left-1/2 transform -translate-x-1/2 
         bg-blue-500 text-white px-4 py-1 rounded-full text-sm font-semibold;
}
.plan-header {
  @apply text-center mb-6;
}
.price-section {
  @apply text-center mb-6;
}
.price {
  @apply text-4xl font-bold;
}
.interval {
  @apply text-gray-500 ml-1;
}
.features-list {
  @apply space-y-3 mb-8;
}
.feature-item {
  @apply flex items-center text-gray-600;
}
.subscribe-btn {
  @apply w-full bg-blue-500 text-white py-3 rounded-lg font-semibold
         hover:bg-blue-600 transition duration-200;
}
.current-subscription {
  @apply mt-12 p-6 bg-gray-50 rounded-lg;
}
.cancel-btn {
  @apply mt-4 bg-red-500 text-white px-6 py-2 rounded-lg
         hover:bg-red-600 transition duration-200;
}
.resume-btn {
  @apply mt-4 ml-4 bg-green-500 text-white px-6 py-2 rounded-lg
         hover:bg-green-600 transition duration-200;
}
</style>

API 路由

// routes/api.php
Route::prefix('subscription')->group(function () {
    Route::post('checkout', [SubscriptionController::class, 'createCheckout']);
    Route::post('swap', [SubscriptionController::class, 'swapSubscription']);
    Route::post('cancel', [SubscriptionController::class, 'cancelSubscription']);
    Route::post('resume', [SubscriptionController::class, 'resumeSubscription']);
    Route::get('status', [SubscriptionController::class, 'getStatus']);
    Route::get('invoices', [SubscriptionController::class, 'getInvoices']);
});
Route::get('plans', [PlanController::class, 'index']);
// Webhook 不需要认证
Route::post('stripe/webhook', [StripeWebhookController::class, 'handleWebhook']);

订阅控制器

<?php
namespace App\Http\Controllers\Api;
use App\Models\Plan;
use App\Models\User;
use App\Services\SubscriptionService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class SubscriptionController extends Controller
{
    protected $subscriptionService;
    public function __construct(SubscriptionService $subscriptionService)
    {
        $this->subscriptionService = $subscriptionService;
        $this->middleware('auth:sanctum');
    }
    // 创建结账
    public function createCheckout(Request $request)
    {
        $request->validate([
            'plan_id' => 'required|exists:plans,id'
        ]);
        $plan = Plan::findOrFail($request->plan_id);
        $user = Auth::user();
        try {
            $session = $this->subscriptionService->createCheckoutSession($user, $plan);
            return response()->json([
                'url' => $session->url
            ]);
        } catch (\Exception $e) {
            return response()->json([
                'message' => '创建结账会话失败'
            ], 500);
        }
    }
    // 获取订阅状态
    public function getStatus()
    {
        $user = Auth::user();
        $status = $this->subscriptionService->getSubscriptionStatus($user);
        return response()->json($status);
    }
    // 获取发票
    public function getInvoices()
    {
        $user = Auth::user();
        if (!$user->hasStripeId()) {
            return response()->json([]);
        }
        $invoices = $user->invoices()->map(function ($invoice) {
            return [
                'id' => $invoice->id,
                'amount' => $invoice->total(),
                'date' => $invoice->date()->toFormattedDateString(),
                'status' => $invoice->status,
                'pdf' => $invoice->invoice_pdf
            ];
        });
        return response()->json($invoices);
    }
}

计划控制器

<?php
namespace App\Http\Controllers\Api;
use App\Models\Plan;
use Illuminate\Http\Request;
class PlanController extends Controller
{
    public function index()
    {
        $plans = Plan::active()->get()->map(function ($plan) {
            return [
                'id' => $plan->id,
                'name' => $plan->name,
                'slug' => $plan->slug,
                'description' => $plan->description,
                'price' => $plan->price,
                'formatted_price' => $plan->formattedPrice(),
                'currency' => $plan->currency,
                'interval' => $plan->interval,
                'interval_description' => $plan->intervalDescription(),
                'features' => $plan->features,
                'is_popular' => $plan->is_popular,
                'trial_period_days' => $plan->trial_period_days
            ];
        });
        return response()->json([
            'data' => $plans
        ]);
    }
}

数据库种子数据

<?php
namespace Database\Seeders;
use App\Models\Plan;
use Illuminate\Database\Seeder;
class PlanSeeder extends Seeder
{
    public function run()
    {
        $plans = [
            [
                'name' => '基础版',
                'slug' => 'basic',
                'stripe_price_id' => 'price_basic_monthly',
                'description' => '适合个人用户',
                'price' => 999, // $9.99
                'currency' => 'USD',
                'interval' => 'month',
                'interval_count' => 1,
                'trial_period_days' => 7,
                'features' => json_encode([
                    '5个项目',
                    '基础支持',
                    '1GB存储空间',
                    '社区访问'
                ]),
                'sort_order' => 1,
                'is_active' => true,
                'is_popular' => false
            ],
            [
                'name' => '专业版',
                'slug' => 'pro',
                'stripe_price_id' => 'price_pro_monthly',
                'description' => '适合专业开发者',
                'price' => 2999, // $29.99
                'currency' => 'USD',
                'interval' => 'month',
                'interval_count' => 1,
                'trial_period_days' => 14,
                'features' => json_encode([
                    '不限项目',
                    '优先支持',
                    '50GB存储空间',
                    'API访问',
                    '团队协作',
                    '高级分析'
                ]),
                'sort_order' => 2,
                'is_active' => true,
                'is_popular' => true
            ],
            [
                'name' => '企业版',
                'slug' => 'enterprise',
                'stripe_price_id' => 'price_enterprise_monthly',
                'description' => '适合大型团队',
                'price' => 9999, // $99.99
                'currency' => 'USD',
                'interval' => 'month',
                'interval_count' => 1,
                'trial_period_days' => 30,
                'features' => json_encode([
                    '不限项目',
                    '24/7专属支持',
                    '500GB存储空间',
                    '完整API访问',
                    '高级团队协作',
                    '自定义功能',
                    'SSO集成',
                    '专属客户经理'
                ]),
                'sort_order' => 3,
                'is_active' => true,
                'is_popular' => false
            ]
        ];
        foreach ($plans as $plan) {
            Plan::create($plan);
        }
    }
}

环境配置

# .env 文件配置
STRIPE_KEY=pk_test_your_publishable_key
STRIPE_SECRET=sk_test_your_secret_key
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret
CASHIER_CURRENCY=usd
CASHIER_CURRENCY_LOCALE=en

这个完整的订阅系统包含:

  1. 数据库设计 - 灵活的计划和订阅模型
  2. 订阅管理 - 创建、升级、降级、取消、恢复
  3. 支付集成 - Stripe 结账、发票管理
  4. Webhook 处理 - 支付成功/失败、订阅取消
  5. 前端组件 - 响应式的定价页面
  6. API 接口 - RESTful 的订阅管理 API
  7. 安全措施 - 认证、验证、错误处理

可以根据需求扩展功能,

  • 多货币支持
  • 优惠券/折扣
  • 试用期管理
  • 订阅提醒
  • 用量计费

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