交易工具包 (Paddle)

未匹配的标注
本文档最新版为 10.x,旧版本可能放弃维护,推荐阅读最新版!

Laravel Cashier(Paddle)

简介

警告
本文档适用于 Cashier Paddle 2.x 与 Paddle 计费集成。如果你仍在使用 Paddle Classic,请使用 Cashier Paddle 1.x

Laravel Cashier Paddle 提供了一个富有表现力、流畅的界面,用于Paddle的订阅计费服务。它处理几乎所有你所担心的样板式订阅计费代码。除了基本的订阅管理外,Cashier 还可以处理:订阅切换、订阅“数量”、订阅暂停、取消期限宽限等。

在深入了解 Cashier Paddle 之前,我们建议你也查看 Paddle 的 概念指南API 文档

升级 Cashier

在升级到 Cashier 的新版本时,重要的是仔细阅读 升级指南

安装

首先,使用 Composer 包管理器安装 Paddle 的 Cashier 包:

composer require laravel/cashier-paddle

接下来,你应该使用 vendor:publish Artisan 命令发布 Cashier 迁移文件:

php artisan vendor:publish --tag="cashier-migrations"

然后,你应该运行应用程序的数据库迁移。Cashier 迁移将创建一个新的 customers 表。此外,将创建新的 subscriptionssubscription_items 表来存储所有客户的订阅。最后,将创建一个新的 transactions 表来存储与你的客户关联的所有 Paddle 交易:

php artisan migrate

[!WARNING]
为确保 Cashier 正确处理所有 Paddle 事件,请记得 设置 Cashier 的 Webhook 处理

Paddle 沙盒

在本地开发和测试阶段,你应该 注册一个 Paddle 沙盒账户。这个账户将为你提供一个沙盒环境,用于测试和开发你的应用程序,而不进行实际支付。你可以使用 Paddle 的 测试卡号 来模拟各种支付情景。

当使用 Paddle 沙盒环境时,你应该在应用程序的 .env 文件中将 PADDLE_SANDBOX 环境变量设置为 true

PADDLE_SANDBOX=true

开发完成后,你可以申请 Paddle 商家账户。在将你的应用程序投入生产之前,Paddle 需要批准你的应用程序域名。

配置

可账单模型

在使用 Cashier 之前,你必须将 Billable 特性添加到用户模型定义中。该特性提供了各种方法,允许你执行常见的计费任务,如创建订阅和更新付款方式信息:

use Laravel\Paddle\Billable;

class User extends Authenticatable
{
    use Billable;
}

如果有不是用户的可账单实体,你也可以将该特性添加到这些类中:

use Illuminate\Database\Eloquent\Model;
use Laravel\Paddle\Billable;

class Team extends Model
{
    use Billable;
}

API 密钥

接下来,你应该在应用程序的 .env 文件中配置你的 Paddle 密钥。你可以从 Paddle 控制面板中检索到你的 Paddle API 密钥:

PADDLE_CLIENT_SIDE_TOKEN=your-paddle-client-side-token
PADDLE_API_KEY=your-paddle-api-key
PADDLE_RETAIN_KEY=your-paddle-retain-key
PADDLE_WEBHOOK_SECRET="your-paddle-webhook-secret"
PADDLE_SANDBOX=true

当你使用Paddle 的沙盒环境时,PADDLE_SANDBOX 环境变量应设置为 true。如果你将应用程序部署到生产环境并使用 Paddle 的实际商家环境,则应将 PADDLE_SANDBOX 变量设置为 false

PADDLE_RETAIN_KEY 是可选的,只有在你使用 Paddle 与 Retain 时才需要设置。

Paddle JS

Paddle 依赖于自己的 JavaScript 库来启动 Paddle 结账小部件。你可以通过在应用程序布局的闭合 </head> 标签之前放置 @paddleJS Blade 指令来加载 JavaScript 库:

<head>
    ...

    @paddleJS
</head>

货币配置

你可以指定一个区域设置,用于在发票上显示货币值时进行格式化。在内部,Cashier 利用 PHP 的 NumberFormatter 来设置货币区域设置:

CASHIER_CURRENCY_LOCALE=nl_BE

警告
若要使用除 en 外的区域设置,请确保在服务器上安装并配置了 ext-intl PHP 扩展。

覆盖默认模型

你可以通过定义自己的模型并扩展相应的 Cashier 模型来自由扩展 Cashier 内部使用的模型:

use Laravel\Paddle\Subscription as CashierSubscription;

class Subscription extends CashierSubscription
{
    // ...
}

定义完你的模型后,你可以通过 Laravel\Paddle\Cashier 类指示 Cashier 使用你的自定义模型。通常,在应用程序的 App\Providers\AppServiceProvider 类的 boot 方法中,你应该通知 Cashier 关于你的自定义模型:

use App\Models\Cashier\Subscription;
use App\Models\Cashier\Transaction;

/**
 * 引导任何应用服务。
 */
public function boot(): void
{
    Cashier::useSubscriptionModel(Subscription::class);
    Cashier::useTransactionModel(Transaction::class);
}

快速入门

销售产品

注意
在使用 Paddle 结账之前,你应该在 Paddle 仪表板中定义具有固定价格的产品。此外,你应该配置 Paddle 的 Webhook 处理

通过你的应用程序提供产品和订阅计费可能会让人望而生畏。然而,借助 Cashier 和 Paddle 的结账覆盖层,你可以轻松构建现代、强大的支付集成。

为了向客户收取非重复性、单次收费产品的费用,我们将利用 Cashier 通过 Paddle 的结账覆盖层向客户收费,在那里他们将提供他们的付款详细信息并确认购买。一旦通过结账覆盖层进行了支付,客户将被重定向到你在应用程序中选择的成功 URL:

use Illuminate\Http\Request;

Route::get('/buy', function (Request $request) {
    $checkout = $request->user()->checkout('pri_deluxe_album')
        ->returnTo(route('dashboard'));

    return view('buy', ['checkout' => $checkout]);
})->name('checkout');

正如上面的示例所示,我们将利用 Cashier 提供的 checkout 方法来创建一个结账对象,向客户展示 Paddle 结账覆盖层,针对给定的「价格标识符」。在使用 Paddle 时,「价格」指的是针对特定产品定义的价格

如果有必要,checkout 方法将自动在 Paddle 中创建一个客户,并将该 Paddle 客户记录连接到你的应用程序数据库中相应的用户。完成结账会话后,客户将被重定向到一个专门的成功页面,在那里你可以向客户显示信息性消息。

buy 视图中,我们将包含一个按钮来显示结账覆盖层。paddle-button Blade 组件与 Cashier Paddle 一起提供;但是,你也可以手动呈现覆盖层结账

<x-paddle-button :checkout="$checkout" class="px-8 py-4">
    购买产品
</x-paddle-button>

向 Paddle 结账提供元数据

在销售产品时,通过你自己应用程序定义的 CartOrder 模型,跟踪已完成的订单和购买的产品是很常见的。当将客户重定向到 Paddle 的结账覆盖层以完成购买时,你可能需要提供一个现有的订单标识符,以便在客户重定向回你的应用程序时将已完成的购买与相应的订单关联起来。

为了实现这一点,你可以向 checkout 方法提供一个自定义数据数组。让我们假设在我们的应用程序中,当用户开始结账过程时,会创建一个待处理的 Order。请记住,此示例中的 CartOrder 模型仅用于说明,Cashier 不提供这些模型。你可以根据你的应用程序需求自由实现这些概念:

use App\Models\Cart;
use App\Models\Order;
use Illuminate\Http\Request;

Route::get('/cart/{cart}/checkout', function (Request $request, Cart $cart) {
    $order = Order::create([
        'cart_id' => $cart->id,
        'price_ids' => $cart->price_ids,
        'status' => 'incomplete',
    ]);

    $checkout = $request->user()->checkout($order->price_ids)
        ->customData(['order_id' => $order->id]);

    return view('billing', ['checkout' => $checkout]);
})->name('checkout');

正如上面的示例所示,当用户开始结账过程时,我们将向 checkout 方法提供所有购物车/订单关联的 Paddle 价格标识符。当客户将这些项目添加到购物车或订单时,你的应用程序负责将它们与之关联。我们还通过 customData 方法向 Paddle 结账覆盖层提供订单的 ID。

当客户完成结账过程后,你可能希望将订单标记为“完成”。为了实现这一点,你可以监听 Paddle 发送的 Webhook,并通过 Cashier 触发的事件来将订单信息存储在你的数据库中。

要开始,请监听 Cashier 发送的 TransactionCompleted 事件。通常情况下,你应该在应用程序的 AppServiceProviderboot 方法中注册事件监听器:

use App\Listeners\CompleteOrder;
use Illuminate\Support\Facades\Event;
use Laravel\Paddle\Events\TransactionCompleted;

/**
 * 引导任何应用服务。
 */
public function boot(): void
{
    Event::listen(TransactionCompleted::class, CompleteOrder::class);
}

在这个示例中,CompleteOrder 监听器可能如下所示:

namespace App\Listeners;

use App\Models\Order;
use Laravel\Cashier\Cashier;
use Laravel\Cashier\Events\TransactionCompleted;

class CompleteOrder
{
    /**
     * 处理传入的 Cashier Webhook 事件。
     */
    public function handle(TransactionCompleted $event): void
    {
        $orderId = $event->payload['data']['custom_data']['order_id'] ?? null;

        $order = Order::findOrFail($orderId);

        $order->update(['status' => 'completed']);
    }
}

请参考 Paddle 的文档,了解有关 transaction.completed 事件包含的数据的更多信息。

销售订阅

注意
在使用 Paddle 结账之前,你应该在 Paddle 仪表板中定义具有固定价格的产品。此外,你应该配置 Paddle 的 Webhook 处理

通过你的应用程序提供产品和订阅计费可能会让人感到畏惧。然而,借助 Cashier 和 Paddle 的结账覆盖层,你可以轻松构建现代、强大的支付集成。

要了解如何使用 Cashier 和 Paddle 的结账覆盖层销售订阅,让我们考虑一个简单的场景,即一个具有基本月度(price_basic_monthly)和年度(price_basic_yearly)计划的订阅服务。这两个价格可以在我们的 Paddle 仪表板下的“基本”产品(pro_basic)下进行分组。此外,我们的订阅服务可能还提供一个专家计划作为 pro_expert

首先,让我们了解客户如何订阅我们的服务。当然,你可以想象客户可能会在我们应用程序的定价页面上点击“订阅”按钮以选择基本计划。此按钮将为他们选择的计划调用 Paddle 结账覆盖层。要开始,请通过 checkout 方法启动结账会话:

use Illuminate\Http\Request;

Route::get('/subscribe', function (Request $request) {
    $checkout = $request->user()->checkout('price_basic_monthly')
        ->returnTo(route('dashboard'));

    return view('subscribe', ['checkout' => $checkout]);
})->name('subscribe');

subscribe 视图中,我们将包含一个按钮来显示结账覆盖层。paddle-button Blade 组件与 Cashier Paddle 一起提供;但是,你也可以手动呈现覆盖层结账

<x-paddle-button :checkout="$checkout" class="px-8 py-4">
    订阅
</x-paddle-button>

当客户点击订阅按钮时,他们将能够输入付款详细信息并启动他们的订阅。为了知道他们的订阅实际上何时开始(因为一些支付方式需要几秒钟来处理),你还应该配置 Cashier 的 Webhook 处理

现在客户可以开始订阅了,我们需要限制应用程序的某些部分,以便只有订阅用户才能访问它们。当然,我们始终可以通过 Cashier 的 Billable 特性提供的 subscribed 方法来确定用户当前的订阅状态:

@if ($user->subscribed())
    <p>你已订阅。</p>
@endif

我们甚至可以轻松确定用户是否订阅了特定产品或价格:

@if ($user->subscribedToProduct('pro_basic'))
    <p>你已订阅我们的基本产品。</p>
@endif

@if ($user->subscribedToPrice('price_basic_monthly'))
    <p>你已订阅我们的月度基本计划。</p>
@endif

构建一个订阅中间件

为了方便起见,你可能希望创建一个中间件,用于确定传入请求是否来自订阅用户。一旦定义了这个中间件,你可以轻松地将其分配给一个路由,以防止未订阅的用户访问该路由:

<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class Subscribed
{
    /**
     * 处理传入请求。
     */
    public function handle(Request $request, Closure $next): Response
    {
        if (! $request->user()?->subscribed()) {
            // 将用户重定向到付款页面并要求他们订阅...
            return redirect('/subscribe');
        }

        return $next($request);
    }
}

一旦定义了中间件,你可以将其分配给一个路由:

use App\Http\Middleware\Subscribed;

Route::get('/dashboard', function () {
    // ...
})->middleware([Subscribed::class]);

允许客户管理他们的计费计划

当然,客户可能希望将他们的订阅计划更改为另一个产品或“层级”。在上面的示例中,我们希望允许客户将他们的计划从月度订阅更改为年度订阅。为此,你需要实现类似以下路由的按钮:

use Illuminate\Http\Request;

Route::put('/subscription/{price}/swap', function (Request $request, $price) {
    $user->subscription()->swap($price); // 在这个示例中,"$price" 为 "price_basic_yearly"。

    return redirect()->route('dashboard');
})->name('subscription.swap');

除了更改计划,你还需要允许客户取消他们的订阅。类似更改计划,提供一个按钮,指向以下路由:

use Illuminate\Http\Request;

Route::put('/subscription/cancel', function (Request $request, $price) {
    $user->subscription()->cancel();

    return redirect()->route('dashboard');
})->name('subscription.cancel');

现在,你的订阅将在其计费周期结束时被取消。

注意
只要你已经配置了 Cashier 的 Webhook 处理,Cashier 将通过检查来自 Paddle 的传入 Webhook 自动保持你的应用程序的与 Cashier 相关的数据库表同步。因此,例如,当你通过 Paddle 的仪表板取消客户的订阅时,Cashier 将接收相应的 Webhook 并在你的应用程序数据库中将订阅标记为“已取消”。

结账会话

大多数向客户收费的操作都是通过 Paddle 的结账覆盖窗口小部件或利用内联结账来执行的。

在使用 Paddle 处理结账付款之前,你应该在 Paddle 结账设置仪表板中定义你的应用程序的默认付款链接

覆盖窗口结账

在显示结账覆盖窗口小部件之前,你必须使用 Cashier 生成一个结账会话。结账会话将通知结账小部件应执行的计费操作:

use Illuminate\Http\Request;

Route::get('/buy', function (Request $request) {
    $checkout = $user->checkout('pri_34567')
        ->returnTo(route('dashboard'));

    return view('billing', ['checkout' => $checkout]);
});

Cashier 包含一个 paddle-button Blade 组件。你可以将结账会话作为 "prop" 传递给这个组件。然后,当点击此按钮时,Paddle 的结账小部件将显示:

<x-paddle-button :checkout="$checkout" class="px-8 py-4">
    Subscribe
</x-paddle-button>

默认情况下,这将使用 Paddle 的默认样式显示小部件。你可以通过向组件添加Paddle 支持的属性,如 data-theme='light' 属性来自定义小部件:

<x-paddle-button :url="$payLink" class="px-8 py-4" data-theme="light">
    Subscribe
</x-paddle-button>

Paddle 结账小部件是异步的。一旦用户在小部件内创建了一个订阅,Paddle 将向你的应用程序发送一个 Webhook,以便你可以正确更新应用程序数据库中的订阅状态。因此,重要的是你正确地设置 Webhooks以适应来自 Paddle 的状态更改。

警告
在订阅状态更改后,接收相应 Webhook 的延迟通常很小,但你应该在你的应用程序中考虑到这一点,考虑到用户的订阅可能在完成结账后不会立即可用。

手动渲染覆盖窗口结账

你也可以在不使用 Laravel 内置 Blade 组件的情况下手动渲染覆盖窗口结账。首先,生成结账会话如前面示例所示

use Illuminate\Http\Request;

Route::get('/buy', function (Request $request) {
    $checkout = $user->checkout('pri_34567')
        ->returnTo(route('dashboard'));

    return view('billing', ['checkout' => $checkout]);
});

接下来,你可以使用 Paddle.js 来初始化结账。在这个示例中,我们将创建一个链接,分配 paddle_button 类。Paddle.js 将检测到这个类,并在点击链接时显示覆盖窗口结账:

<?php
$items = $checkout->getItems();
$customer = $checkout->getCustomer();
$custom = $checkout->getCustomData();
?>

<a
href='#!'
class='paddle_button'
data-items='{!! json_encode($items) !!}'
@if ($customer) data-customer-id='{{ $customer->paddle_id }}' @endif
@if ($custom) data-custom-data='{{ json_encode($custom) }}' @endif
@if ($returnUrl = $checkout->getReturnUrl()) data-success-url='{{ $returnUrl }}' @endif
>
购买产品
</a>

内联结账

如果你不想使用 Paddle 的“覆盖”样式结账小部件,Paddle 还提供了在页面内显示小部件的选项。虽然这种方法不允许你调整任何结账的 HTML 字段,但它允许你将小部件嵌入到你的应用程序中。

为了让你更容易开始使用内联结账,Cashier 包含了一个 paddle-checkout Blade 组件。要开始,你应该生成一个结账会话

use Illuminate\Http\Request;

Route::get('/buy', function (Request $request) {
    $checkout = $user->checkout('pri_34567')
        ->returnTo(route('dashboard'));

    return view('billing', ['checkout' => $checkout]);
});

然后,你可以将结账会话传递给组件的 checkout 属性:

<x-paddle-checkout :checkout="$checkout" class="w-full" />

要调整内联结账组件的高度,你可以将 height 属性传递给 Blade 组件:

<x-paddle-checkout :checkout="$checkout" class="w-full" height="500" />

请参考 Paddle 的内联结账指南可用结账设置以获取有关内联结账自定义选项的更多详细信息。

手动渲染内联结账

你也可以在不使用 Laravel 内置 Blade 组件的情况下手动渲染内联结账。首先,生成结账会话如前面示例所示

use Illuminate\Http\Request;

Route::get('/buy', function (Request $request) {
    $checkout = $user->checkout('pri_34567')
        ->returnTo(route('dashboard'));

    return view('billing', ['checkout' => $checkout]);
});

接下来,你可以使用 Paddle.js 来初始化结账。在这个示例中,我们将使用Alpine.js来演示;然而,你可以自由修改此示例以适应你自己的前端堆栈:

<?php
$options = $checkout->options();

$options['settings']['frameTarget'] = 'paddle-checkout';
$options['settings']['frameInitialHeight'] = 366;
?>

<div class="paddle-checkout" x-data="{}" x-init="
    Paddle.Checkout.open(@json($options));
">
</div>

访客结账

有时,你可能需要为不需要在你的应用程序中拥有帐户的用户创建一个结账会话。为此,你可以使用 guest 方法:

use Illuminate\Http\Request;
use Laravel\Paddle\Checkout;

Route::get('/buy', function (Request $request) {
    $checkout = Checkout::guest('pri_34567')
        ->returnTo(route('home'));

    return view('billing', ['checkout' => $checkout]);
});

然后,你可以将结账会话提供给 Paddle 按钮内联结账 Blade 组件。

价格预览

Paddle 允许你根据货币自定义价格,基本上允许你为不同国家配置不同的价格。Cashier Paddle 允许你使用 previewPrices 方法检索所有这些价格。该方法接受你希望检索价格的价格 ID:

use Laravel\Paddle\Cashier;

$prices = Cashier::previewPrices(['pri_123', 'pri_456']);

货币将根据请求的 IP 地址确定;但是,你可以选择性地提供一个特定的国家代码来检索价格:

use Laravel\Paddle\Cashier;

$prices = Cashier::previewPrices(['pri_123', 'pri_456'], ['address' => [
    'country_code' => 'BE',
    'postal_code' => '1234',
]]);

在检索价格后,你可以按照你希望的方式显示它们:

<ul>
    @foreach ($prices as $price)
        <li>{{ $price->product['name'] }} - {{ $price->total() }}</li>
    @endforeach
</ul>

你还可以单独显示小计价格和税额:

<ul>
    @foreach ($prices as $price)
        <li>{{ $price->product['name'] }} - {{ $price->subtotal() }} (+ {{ $price->tax() }} 税)</li>
    @endforeach
</ul>

有关更多信息,请参阅Paddle 的关于价格预览的 API 文档

用户价格预览

如果用户已经是客户,并且你想显示适用于该客户的价格,你可以通过直接从客户实例检索价格来实现:

use App\Models\User;

$prices = User::find(1)->previewPrices(['pri_123', 'pri_456']);

在内部,Cashier 将使用用户的客户 ID 来检索他们货币的价格。例如,居住在美国的用户将看到美元价格,而居住在比利时的用户将看到欧元价格。如果找不到匹配的货币,将使用产品的默认货币。你可以在 Paddle 控制面板中自定义产品或订阅计划的所有价格。

折扣

你也可以选择显示折扣后的价格。在调用 previewPrices 方法时,通过 discount_id 选项提供折扣 ID:

use Laravel\Paddle\Cashier;

$prices = Cashier::previewPrices(['pri_123', 'pri_456'], [
    'discount_id' => 'dsc_123'
]);

然后,显示计算后的价格:

<ul>
    @foreach ($prices as $price)
        <li>{{ $price->product['name'] }} - {{ $price->total() }}</li>
    @endforeach
</ul>

客户

客户默认值

Cashier 允许你为创建结账会话时的客户定义一些有用的默认值。设置这些默认值可以预先填充客户的电子邮件地址和姓名,以便他们可以立即转向结账小部件的付款部分。你可以通过覆盖你的可账单模型上的以下方法来设置这些默认值:

/**
 * 获取与 Paddle 关联的客户姓名。
 */
public function paddleName(): string|null
{
    return $this->name;
}

/**
 * 获取与 Paddle 关联的客户电子邮件地址。
 */
public function paddleEmail(): string|null
{
    return $this->email;
}

这些默认值将用于 Cashier 中生成结账会话的每个操作。

检索客户

你可以使用 Cashier::findBillable 方法根据他们的 Paddle 客户 ID 检索客户。该方法将返回可账单模型的实例:

use Laravel\Cashier\Cashier;

$user = Cashier::findBillable($customerId);

创建客户

偶尔,你可能希望创建一个 Paddle 客户而不开始订阅。你可以使用 createAsCustomer 方法来实现:

$customer = $user->createAsCustomer();

返回一个 Laravel\Paddle\Customer 的实例。一旦客户在 Paddle 中创建成功,你可以在以后的某个日期开始订阅。你可以提供一个可选的 $options 数组,以传递任何额外的由 Paddle API 支持的客户创建参数

$customer = $user->createAsCustomer($options);

订阅

创建订阅

要创建一个订阅,首先从数据库中检索你的可账单模型的实例,通常这将是 App\Models\User 的实例。一旦你检索到模型实例,你可以使用 subscribe 方法来创建模型的结账会话:

use Illuminate\Http\Request;

Route::get('/user/subscribe', function (Request $request) {
    $checkout = $request->user()->subscribe($premium = 12345, 'default')
        ->returnTo(route('home'));

    return view('billing', ['checkout' => $checkout]);
});

传递给 subscribe 方法的第一个参数是用户要订阅的特定价格。这个值应该对应于 Paddle 中价格的标识符。returnTo 方法接受一个 URL,在用户成功完成结账后将重定向到该 URL。传递给 subscribe 方法的第二个参数应该是订阅的内部“类型”。如果你的应用程序只提供单个订阅,你可以将其称为 defaultprimary。这个订阅类型仅用于内部应用程序使用,不应显示给用户。此外,它不应包含空格,并且在创建订阅后不应更改。

你还可以使用 customData 方法提供有关订阅的自定义元数据数组:

$checkout = $request->user()->subscribe($premium = 12345, 'default')
    ->customData(['key' => 'value'])
    ->returnTo(route('home'));

一旦创建了订阅结账会话,可以将该结账会话提供给 Cashier Paddle 提供的 paddle-button Blade 组件

<x-paddle-button :checkout="$checkout" class="px-8 py-4">
    Subscribe
</x-paddle-button>

用户完成结账后,Paddle 将发送一个 subscription_created webhook。Cashier 将接收此 webhook 并为你的客户设置订阅。为确保所有 webhook 得到正确接收和处理,请确保你已经正确设置了 webhook 处理

检查订阅状态

一旦用户订阅了你的应用程序,你可以使用各种便捷的方法来检查他们的订阅状态。首先,subscribed 方法在用户拥有有效订阅时返回 true,即使订阅目前处于试用期内:

if ($user->subscribed()) {
    // ...
}

如果你的应用程序提供多个订阅,你可以在调用 subscribed 方法时指定订阅:

if ($user->subscribed('default')) {
    // ...
}

subscribed 方法也非常适合用作路由中间件的候选,允许你根据用户的订阅状态过滤对路由和控制器的访问:

<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class EnsureUserIsSubscribed
{
    /**
     * 处理传入请求。
     *
     * @param  \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response)  $next
     */
    public function handle(Request $request, Closure $next): Response
    {
        if ($request->user() && ! $request->user()->subscribed()) {
            // 这个用户不是付费客户...
            return redirect('billing');
        }

        return $next($request);
    }
}

如果你想确定用户是否仍处于试用期内,可以使用 onTrial 方法。这个方法可用于确定是否应向用户显示警告,告诉他们他们仍处于试用期内:

if ($user->subscription()->onTrial()) {
    // ...
}

subscribedToPrice 方法可用于确定用户是否根据给定的 Paddle 价格 ID 订阅了特定计划。在这个例子中,我们将确定用户的 default 订阅是否已经订阅了月度价格:

if ($user->subscribedToPrice($monthly = 'pri_123', 'default')) {
    // ...
}

recurring 方法可用于确定用户当前是否处于活跃订阅状态,不再处于试用期或宽限期内:

if ($user->subscription()->recurring()) {
    // ...
}

已取消订阅状态

要确定用户曾经是活跃订阅者但已取消订阅,可以使用 canceled 方法:

if ($user->subscription()->canceled()) {
    // ...
}

你还可以确定用户是否已取消订阅,但仍处于“宽限期”直到订阅完全到期。例如,如果用户在3月5日取消了原定于3月10日到期的订阅,用户将在3月10日之前处于“宽限期”。此外,在此期间,subscribed 方法仍将返回 true

if ($user->subscription()->onGracePeriod()) {
    // ...
}

拖欠状态

如果订阅付款失败,它将被标记为 past_due。在订阅处于这种状态时,直到客户更新他们的付款信息,它将不会处于活跃状态。你可以使用订阅实例上的 pastDue 方法来确定订阅是否拖欠:

if ($user->subscription()->pastDue()) {
    // ...
}

当订阅拖欠时,你应指导用户更新他们的付款信息

如果你希望在订阅处于 past_due 状态时仍将其视为有效,可以使用 Cashier 提供的 keepPastDueSubscriptionsActive 方法。通常,这个方法应该在你的 AppServiceProviderregister 方法中调用:

use Laravel\Paddle\Cashier;

/**
 * 注册任何应用程序服务。
 */
public function register(): void
{
    Cashier::keepPastDueSubscriptionsActive();
}

警告
当订阅处于 past_due 状态时,直到付款信息已更新,它不能被更改。因此,当订阅处于 past_due 状态时,swapupdateQuantity 方法将抛出异常。

订阅范围

大多数订阅状态也可作为查询范围,以便你可以轻松地查询处于特定状态的订阅:

// 获取所有有效订阅...
$subscriptions = Subscription::query()->valid()->get();

// 获取用户的所有已取消订阅...
$subscriptions = $user->subscriptions()->canceled()->get();

下面是可用范围的完整列表:

Subscription::query()->valid();
Subscription::query()->onTrial();
Subscription::query()->expiredTrial();
Subscription::query()->notOnTrial();
Subscription::query()->active();
Subscription::query()->recurring();
Subscription::query()->pastDue();
Subscription::query()->paused();
Subscription::query()->notPaused();
Subscription::query()->onPausedGracePeriod();
Subscription::query()->notOnPausedGracePeriod();
Subscription::query()->canceled();
Subscription::query()->notCanceled();
Subscription::query()->onGracePeriod();
Subscription::query()->notOnGracePeriod();

订阅单次收费

订阅单次收费允许你在订阅的基础上向订阅者收取一次性费用。在调用 charge 方法时,你必须提供一个或多个价格 ID:

// 收取单个价格...
$response = $user->subscription()->charge('pri_123');

// 一次性收取多个价格...
$response = $user->subscription()->charge(['pri_123', 'pri_456']);

charge 方法实际上不会在用户的订阅的下一个计费周期之前向用户收费。如果你想立即向用户收费,可以改用 chargeAndInvoice 方法:

$response = $user->subscription()->chargeAndInvoice('pri_123');

更新付款信息

Paddle 总是为每个订阅保存一个付款方式。如果你想更新订阅的默认付款方式,应该使用订阅模型上的 redirectToUpdatePaymentMethod 方法,将客户重定向到 Paddle 的托管付款方式更新页面:

use Illuminate\Http\Request;

Route::get('/update-payment-method', function (Request $request) {
    $user = $request->user();

    return $user->subscription()->redirectToUpdatePaymentMethod();
});

当用户完成信息更新后,Paddle 将会发送一个 subscription_updated webhook,并且订阅详情将会在你的应用程序数据库中更新。

更改计划

用户订阅你的应用程序后,他们可能偶尔想要切换到一个新的订阅计划。要为用户更新订阅计划,应该将 Paddle 价格标识符传递给订阅的 swap 方法:

use App\Models\User;

$user = User::find(1);

$user->subscription()->swap($premium = 'pri_456');

如果你想要立即切换计划并向用户开具发票,而不必等待他们的下一个计费周期,可以使用 swapAndInvoice 方法:

$user = User::find(1);

$user->subscription()->swapAndInvoice($premium = 'pri_456');

比例调整

默认情况下,Paddle 在不同计划之间切换时会按比例计算费用。noProrate 方法可用于在不按比例计费的情况下更新订阅:

$user->subscription('default')->noProrate()->swap($premium = 'pri_456');

如果你想禁用比例调整并立即向客户开具发票,可以结合使用 swapAndInvoice 方法和 noProrate 方法:

$user->subscription('default')->noProrate()->swapAndInvoice($premium = 'pri_456');

或者,如果你不想为订阅更改向客户开具账单,可以使用 doNotBill 方法:

$user->subscription('default')->doNotBill()->swap($premium = 'pri_456');

欲了解更多关于 Paddle 的比例调整政策,请参阅 Paddle 的比例调整文档

订阅数量

有时订阅会受到“数量”的影响。例如,项目管理应用程序可能会按每个项目每月 $10 收费。要轻松增加或减少订阅的数量,请使用 incrementQuantitydecrementQuantity 方法:

$user = User::find(1);

$user->subscription()->incrementQuantity();

// 将五个添加到订阅的当前数量...
$user->subscription()->incrementQuantity(5);

$user->subscription()->decrementQuantity();

// 从订阅的当前数量减去五个...
$user->subscription()->decrementQuantity(5);

或者,你可以使用 updateQuantity 方法设置特定的数量:

$user->subscription()->updateQuantity(10);

使用 noProrate 方法可以在不按比例计费的情况下更新订阅的数量:

$user->subscription()->noProrate()->updateQuantity(10);

带有多个产品的订阅数量

如果你的订阅是带有多个产品的订阅,应将你希望增加或减少数量的价格 ID 作为第二个参数传递给增加/减少方法:

$user->subscription()->incrementQuantity(1, 'price_chat');

带有多个产品的订阅

带有多个产品的订阅允许你为单个订阅分配多个计费产品。例如,假设你正在构建一个客户服务“帮助台”应用程序,基本订阅价格为每月 $10,但提供额外的每月 $15 的实时聊天附加产品。

在创建订阅结账会话时,你可以通过将价格数组作为 subscribe 方法的第一个参数来为给定订阅指定多个产品:

use Illuminate\Http\Request;

Route::post('/user/subscribe', function (Request $request) {
    $checkout = $request->user()->subscribe([
        'price_monthly',
        'price_chat',
    ]);

    return view('billing', ['checkout' => $checkout]);
});

在上面的示例中,客户将有两个价格附加到他们的 default 订阅上。这两个价格将在各自的计费间隔上收费。如果需要,你可以传递一个键/值对的关联数组来指示每个价格的特定数量:

$user = User::find(1);

$checkout = $user->subscribe('default', ['price_monthly', 'price_chat' => 5]);

如果你想向现有订阅添加另一个价格,必须使用订阅的 swap 方法。在调用 swap 方法时,你还应包括订阅的当前价格和数量:

$user = User::find(1);

$user->subscription()->swap(['price_chat', 'price_original' => 2]);

上面的示例将添加新价格,但客户直到下一个计费周期才会被收费。如果你想立即向客户收费,可以使用 swapAndInvoice 方法:

$user->subscription()->swapAndInvoice(['price_chat', 'price_original' => 2]);

你可以使用 swap 方法从订阅中移除价格,并省略你想要移除的价格:

$user->subscription()->swap(['price_original' => 2]);

警告
你不能移除订阅中的最后一个价格。相反,你应该简单地取消订阅。

多个订阅

Paddle 允许你的客户同时拥有多个订阅。例如,你可能经营一个健身房,提供游泳订阅和举重订阅,每个订阅可能有不同的定价。当然,客户应该能够订阅其中一个或两个计划。

当你的应用程序创建订阅时,你可以将订阅类型作为第二个参数传递给 subscribe 方法。类型可以是任何表示用户发起的订阅类型的字符串:

use Illuminate\Http\Request;

Route::post('/swimming/subscribe', function (Request $request) {
    $checkout = $request->user()->subscribe($swimmingMonthly = 'pri_123', 'swimming');

    return view('billing', ['checkout' => $checkout]);
});

在这个示例中,我们为客户启动了一个每月的游泳订阅。然而,他们可能会在以后想要切换到年度订阅。当调整客户的订阅时,我们可以简单地在 swimming 订阅上交换价格:

$user->subscription('swimming')->swap($swimmingYearly = 'pri_456');

当然,你也可以完全取消订阅:

$user->subscription('swimming')->cancel();

暂停订阅

要暂停订阅,请在用户的订阅上调用 pause 方法:

$user->subscription()->pause();

当订阅暂停时,Cashier 将自动在你的数据库中设置 paused_at 列。该列用于确定 paused 方法何时应开始返回 true。例如,如果客户在3月1日暂停了订阅,但订阅原定于3月5日才重新发生,paused 方法将继续返回 false,直到3月5日。这是因为通常允许用户在他们的计费周期结束之前继续使用应用程序。

默认情况下,暂停将在下一个计费间隔发生,以便客户可以使用他们支付的剩余时间。如果你想立即暂停订阅,可以使用 pauseNow 方法:

$user->subscription()->pauseNow();

使用 pauseUntil 方法,你可以将订阅暂停直到特定时间:

$user->subscription()->pauseUntil(now()->addMonth());

或者,你可以使用 pauseNowUntil 方法立即暂停订阅直到给定时间点:

$user->subscription()->pauseNowUntil(now()->addMonth());

你可以使用 onPausedGracePeriod 方法确定用户是否已暂停他们的订阅,但仍处于“宽限期”:

if ($user->subscription()->onPausedGracePeriod()) {
    // ...
}

要恢复暂停的订阅,你可以在订阅上调用 resume 方法:

$user->subscription()->resume();

警告
订阅在暂停期间无法修改。如果你想切换到不同的计划或更新数量,你必须先恢复订阅。

取消订阅

要取消订阅,请在用户的订阅上调用 cancel 方法:

$user->subscription()->cancel();

当订阅被取消时,Cashier 将自动在你的数据库中设置 ends_at 列。该列用于确定 subscribed 方法何时应开始返回 false。例如,如果客户在3月1日取消了订阅,但订阅原定于3月5日才结束,subscribed 方法将继续返回 true,直到3月5日。这是因为通常允许用户在他们的计费周期结束之前继续使用应用程序。

你可以使用 onGracePeriod 方法确定用户是否已取消他们的订阅,但仍处于“宽限期”:

if ($user->subscription()->onGracePeriod()) {
    // ...
}

如果你希望立即取消订阅,你可以在订阅上调用 cancelNow 方法:

$user->subscription()->cancelNow();

要阻止处于宽限期的订阅被取消,你可以调用 stopCancelation 方法:

$user->subscription()->stopCancelation();

[!WARNING]
Paddle 的订阅在取消后无法恢复。如果你的客户希望恢复他们的订阅,他们将不得不创建一个新的订阅。

订阅试用

预先设置付款方式

如果你想为客户提供试用期,同时仍然在一开始收集付款方式信息,你应该在 Paddle 仪表板上为客户订阅的价格设置一个试用时间。然后,像平常一样启动结账会话:

use Illuminate\Http\Request;

Route::get('/user/subscribe', function (Request $request) {
    $checkout = $request->user()->subscribe('pri_monthly')
                ->returnTo(route('home'));

    return view('billing', ['checkout' => $checkout]);
});

当你的应用程序接收到 subscription_created 事件时,Cashier 将在应用程序数据库中的订阅记录上设置试用期结束日期,并指示 Paddle 在此日期之后开始向客户计费。

警告
如果客户的订阅在试用结束日期之前未取消,他们将在试用到期后立即收费,因此你应该确保通知用户他们的试用结束日期。

你可以使用用户实例的 onTrial 方法或订阅实例的 onTrial 方法来确定用户是否在试用期内。以下两个示例是等效的:

if ($user->onTrial()) {
    // ...
}

if ($user->subscription()->onTrial()) {
    // ...
}

要确定现有试用是否已过期,你可以使用 hasExpiredTrial 方法:

if ($user->hasExpiredTrial()) {
    // ...
}

if ($user->subscription()->hasExpiredTrial()) {
    // ...
}

要确定用户是否针对特定订阅类型处于试用期,你可以向 onTrialhasExpiredTrial 方法提供类型:

if ($user->onTrial('default')) {
    // ...
}

if ($user->hasExpiredTrial('default')) {
    // ...
}

不预先设置付款方式

如果你想在不预先收集用户付款方式信息的情况下提供试用期,你可以在附加到用户的客户记录上设置 trial_ends_at 列为你期望的试用结束日期。这通常在用户注册期间完成:

use App\Models\User;

$user = User::create([
    // ...
]);

$user->createAsCustomer([
    'trial_ends_at' => now()->addDays(10)
]);

Cashier 将此类型的试用称为“通用试用”,因为它不附加到任何现有订阅。User 实例上的 onTrial 方法将在当前日期未超过 trial_ends_at 的值时返回 true

if ($user->onTrial()) {
    // 用户处于试用期内...
}

一旦你准备为用户创建实际订阅,你可以像平常一样使用 subscribe 方法:

use Illuminate\Http\Request;

Route::get('/user/subscribe', function (Request $request) {
    $checkout = $user->subscribe('pri_monthly')
        ->returnTo(route('home'));

    return view('billing', ['checkout' => $checkout]);
});

要获取用户的试用结束日期,你可以使用 trialEndsAt 方法。如果用户在试用期内,该方法将返回一个 Carbon 日期实例,如果他们不在试用期内,则返回 null。如果你想获取除默认订阅以外的特定订阅的试用结束日期,你也可以传递一个可选的订阅类型参数:

if ($user->onTrial('default')) {
    $trialEndsAt = $user->trialEndsAt();
}

如果你想具体知道用户是否在他们的“通用”试用期内并且尚未创建实际订阅,你可以使用 onGenericTrial 方法:

if ($user->onGenericTrial()) {
    // 用户处于他们的「通用」试用期内...
}

延长或激活试用期

你可以通过调用 extendTrial 方法并指定试用结束的时间来延长订阅上的现有试用期:

$user->subscription()->extendTrial(now()->addDays(5));

或者,你可以通过在订阅上调用 activate 方法结束其试用期来立即激活订阅:

$user->subscription()->activate();

处理 Paddle Webhooks

Paddle 可以通过 Webhooks 向你的应用程序通知各种事件。默认情况下,Cashier 服务提供程序注册了一个指向 Cashier Webhook 控制器的路由。该控制器将处理所有传入的 Webhook 请求。

默认情况下,此控制器将自动处理取消订阅、订阅更新和付款方式更改等事件;然而,正如我们将很快发现的那样,你可以扩展此控制器以处理任何你喜欢的 Paddle Webhook 事件。

为确保你的应用程序能够处理 Paddle Webhooks,请确保在 Paddle 控制面板中配置 Webhook URL。默认情况下,Cashier 的 Webhook 控制器响应 /paddle/webhook URL 路径。你应该在 Paddle 控制面板中启用的所有 Webhooks 的完整列表包括:

  • Customer Updated
  • Transaction Completed
  • Transaction Updated
  • Subscription Created
  • Subscription Updated
  • Subscription Paused
  • Subscription Canceled

警告
确保使用 Cashier 包含的Webhook 签名验证中间件保护传入请求。

Webhooks 和 CSRF 保护

由于 Paddle Webhooks 需要绕过 Laravel 的 CSRF 保护,你应确保 Laravel 不会尝试验证传入的 Paddle Webhooks 的 CSRF 令牌。为实现此目的,你应在应用程序的 bootstrap/app.php 文件中将 paddle/* 排除在 CSRF 保护之外:

->withMiddleware(function (Middleware $middleware) {
    $middleware->validateCsrfTokens(except: [
        'paddle/*',
    ]);
})

Webhooks 和本地开发

为了让 Paddle 能够在本地开发期间向你的应用程序发送 Webhooks,你需要通过站点共享服务(如NgrokExpose)公开你的应用程序。如果你正在使用 Laravel Sail 在本地开发你的应用程序,你可以使用 Sail 的 站点共享命令

定义 Webhook 事件处理程序

Cashier 自动处理订阅失败付款和其他常见的 Paddle Webhooks 上的取消操作。然而,如果你有其他 Webhook 事件需要处理,你可以通过监听 Cashier 发出的以下事件来实现:

  • Laravel\Paddle\Events\WebhookReceived
  • Laravel\Paddle\Events\WebhookHandled

这两个事件都包含 Paddle Webhook 的完整负载。例如,如果你希望处理 transaction.billed Webhook,你可以注册一个监听器来处理该事件:

<?php

namespace App\Listeners;

use Laravel\Paddle\Events\WebhookReceived;

class PaddleEventListener
{
    /**
     * 处理接收到的 Paddle Webhooks。
     */
    public function handle(WebhookReceived $event): void
    {
        if ($event->payload['event_type'] === 'transaction.billed') {
            // 处理传入的事件...
        }
    }
}

Cashier 还会针对接收到的 Webhook 类型发出专用事件。除了来自 Paddle 的完整负载外,它们还包含用于处理 Webhook 的相关模型,如可计费模型、订阅或收据:

  • Laravel\Paddle\Events\CustomerUpdated
  • Laravel\Paddle\Events\TransactionCompleted
  • Laravel\Paddle\Events\TransactionUpdated
  • Laravel\Paddle\Events\SubscriptionCreated
  • Laravel\Paddle\Events\SubscriptionUpdated
  • Laravel\Paddle\Events\SubscriptionPaused
  • Laravel\Paddle\Events\SubscriptionCanceled

你还可以通过在应用程序的 .env 文件中定义 CASHIER_WEBHOOK 环境变量来覆盖默认的内置 Webhook 路由。此值应为你的 Webhook 路由的完整 URL,并且需要与 Paddle 控制面板中设置的 URL 匹配:

CASHIER_WEBHOOK=https://example.com/my-paddle-webhook-url

验证 Webhook 签名

为了保护你的 Webhooks,你可以使用Paddle 的 Webhook 签名。为了方便起见,Cashier 自动包含一个中间件,用于验证传入的 Paddle Webhook 请求是否有效。

要启用 Webhook 验证,请确保在应用程序的 .env 文件中定义了 PADDLE_WEBHOOK_SECRET 环境变量。Webhook 密钥可以从你的 Paddle 帐户仪表板中获取。

单次收费

为产品收费

如果你想为客户发起产品购买,你可以在可计费模型实例上使用 checkout 方法为购买生成结账会话。checkout 方法接受一个或多个价格 ID。如果需要,可以使用关联数组来提供正在购买的产品数量:

use Illuminate\Http\Request;

Route::get('/buy', function (Request $request) {
    $checkout = $request->user()->checkout(['pri_tshirt', 'pri_socks' => 5]);

    return view('buy', ['checkout' => $checkout]);
});

生成结账会话后,你可以使用 Cashier 提供的 paddle-button Blade 组件来让用户查看 Paddle 结账小部件并完成购买:

<x-paddle-button :checkout="$checkout" class="px-8 py-4">
    购买
</x-paddle-button>

结账会话具有 customData 方法,允许你向底层交易创建传递任何自定义数据。请参考Paddle 文档以了解在传递自定义数据时可用的选项:

$checkout = $user->checkout('pri_tshirt')
    ->customData([
        'custom_option' => $value,
    ]);

退款交易

退款交易将退还已退款金额至客户购买时使用的支付方式。如果你需要退款 Paddle 购买,你可以在 Cashier\Paddle\Transaction 模型上使用 refund 方法。该方法将接受一个原因作为第一个参数,一个或多个价格ID用于退款,可选金额作为关联数组。你可以使用 transactions 方法检索给定可计费模型的交易。

例如,假设我们想要为价格 pri_123pri_456 退款特定交易。我们想要完全退款 pri_123,但只退还 pri_456 两美元:

use App\Models\User;

$user = User::find(1);

$transaction = $user->transactions()->first();

$response = $transaction->refund('意外收费', [
    'pri_123', // 完全退款此价格...
    'pri_456' => 200, // 仅部分退款此价格...
]);

上面的示例退款交易中的特定行项目。如果要退还整个交易,请简单提供一个原因:

$response = $transaction->refund('意外收费');

有关退款的更多信息,请参考Paddle 的退款文档

警告
在完全处理之前,退款必须始终经过 Paddle 批准。

信用交易

与退款类似,你也可以对交易进行信用处理。信用交易将向客户的余额中添加资金,以便将来可以用于购买。只能对手动收取的交易进行信用处理,不能对自动收取的交易(如订阅)进行信用处理,因为 Paddle 会自动处理订阅的信用:

$transaction = $user->transactions()->first();

// 对特定行项目进行全额信用处理...
$response = $transaction->credit('补偿', 'pri_123');

欲了解更多信息,请参阅Paddle 关于信用处理的文档

警告
只能对手动收取的交易进行信用处理。自动收取的交易由 Paddle 自行处理。

交易

你可以通过 transactions 属性轻松检索可计费模型的交易数组:

use App\Models\User;

$user = User::find(1);

$transactions = $user->transactions;

交易代表你的产品和购买的付款,并附带发票。只有已完成的交易才会存储在应用程序的数据库中。

在列出客户的交易时,你可以使用交易实例的方法来显示相关的付款信息。例如,你可以希望在表格中列出每笔交易,让用户可以轻松下载任何发票:

<table>
    @foreach ($transactions as $transaction)
        <tr>
            <td>{{ $transaction->billed_at->toFormattedDateString() }}</td>
            <td>{{ $transaction->total() }}</td>
            <td>{{ $transaction->tax() }}</td>
            <td><a href="{{ route('download-invoice', $transaction->id) }}" target="_blank">下载</a></td>
        </tr>
    @endforeach
</table>

download-invoice 路由可能如下所示:

use Illuminate\Http\Request;
use Laravel\Cashier\Transaction;

Route::get('/download-invoice/{transaction}', function (Request $request, Transaction $transaction) {
    return $transaction->redirectToInvoicePdf();
})->name('download-invoice');

过往和即将到期的付款

你可以使用 lastPaymentnextPayment 方法来检索并显示客户循环订阅的过往或即将到期的付款:

use App\Models\User;

$user = User::find(1);

$subscription = $user->subscription();

$lastPayment = $subscription->lastPayment();
$nextPayment = $subscription->nextPayment();

这两个方法都将返回一个 Laravel\Paddle\Payment 实例;但是,当交易尚未通过 Webhooks 同步时,lastPayment 将返回 null,而当计费周期结束时(例如当订阅已取消时),nextPayment 将返回 null

下次付款:{{ $nextPayment->amount() }},截止日期 {{ $nextPayment->date()->format('d/m/Y') }}

测试

在测试过程中,你应该手动测试你的计费流程,以确保你的集成按预期工作。

对于自动化测试,包括在 CI 环境中执行的测试,你可以使用Laravel 的 HTTP 客户端来模拟对 Paddle 发出的 HTTP 调用。虽然这不会测试来自 Paddle 的实际响应,但它提供了一种在不实际调用 Paddle API 的情况下测试你的应用程序的方法。

本文章首发在 LearnKu.com 网站上。

本译文仅用于学习和交流目的,转载请务必注明文章译者、出处、和本文链接
我们的翻译工作遵照 CC 协议,如果我们的工作有侵犯到您的权益,请及时联系我们。

原文地址:https://learnku.com/docs/laravel/11.x/ca...

译文地址:https://learnku.com/docs/laravel/11.x/ca...

上一篇 下一篇
《L04 微信小程序从零到发布》
从小程序个人账户申请开始,带你一步步进行开发一个微信小程序,直到提交微信控制台上线发布。
《L01 基础入门》
我们将带你从零开发一个项目并部署到线上,本课程教授 Web 开发中专业、实用的技能,如 Git 工作流、Laravel Mix 前端工作流等。
贡献者:1
讨论数量: 0
发起讨论 只看当前版本


暂无话题~