区域性支付网关。以插件发布。拉模型 — 无 webhook。

AcelleMail 中的支付网关是宿主上的 PaymentIntent 与厂商的结账 / 订阅状态之间的桥梁。宿主在内置的 Cashier 包中提供八个网关(Stripe、Stripe Subscription、Braintree、Braintree Subscription、PayPal、Paystack、Razorpay、Offline);其他每家厂商 — Paddle、Lemon Squeezy、区域性提供商、加密轨 — 都以插件发布。架构是基于拉的:宿主按需从厂商拉取状态,不需要 webhook 监听器。本页以 storage/app/plugins/acelle/paddle/ 作为参考的实战示例。

为什么是插件而非核心

通过编辑 <code>app/Cashier/Services/</code>、内置的 <code>vendor/acelle/cashier/</code> 包以及 <code>app/Providers/CheckoutServiceProvider.php</code> 来增加厂商在原则上可行。选择插件路径有四个具体理由:

  • 核心保持封闭。增加厂商的核心升级会把每个安装绑定到该厂商的启动成本上。从不通过 Paddle 收款的商家仍需承担成本,并看到一个无法使用的配置字段。
  • 独立发布。插件可以按厂商 API 的节奏迭代 — Paddle Billing v2、Razorpay Routes、Adyen Checkout API 修订 — 无需等待核心发布。
  • 卸载干净。php artisan plugin:delete acelle/paddle 会彻底移除该网关类型。核心 switch 中不会留下死的 case 'paddle': 分支。
  • 按租户策略。禁用插件会让网关立即在管理员的 Sending Servers 选择类型列表中到处消失。Stripe 与 Offline 内置在核心是因为它们"始终在线";其他一切更适合插件形态。

权衡:插件作者拥有网关服务、重定向控制器、管理员表单视图与读侧映射器(getRemotePlan / getRemoteSubscription / getRemotePaymentMethod)。基础契约让这件事变小 — 完整 Paddle 大约 500-700 行。

拉模型 — 无 webhook

AcelleMail 的网关架构是基于拉的:宿主按需从厂商拉取状态。三种触发器运行读取:

  • 页面加载惰性拉取 — 当本地状态超过新鲜度阈值时,客户的订阅 / 发票页面在渲染时调用 getRemoteSubscription
  • "刷新"按钮 — 管理员 / 客户可强制立即读取。
  • 周期性 RemoteSubscriptionSyncService cron — 按可配置的频率扫过每个活跃远程订阅。

无需 webhook 监听器。插件作者需要托管公共 webhook 端点、校验 HMAC 签名、对至少一次投递去重或处理重放攻击。权衡是状态延迟 — 通常几分钟 — 介于厂商确认状态变更与宿主感知之间。对于 SaaS 计费而言这没问题:客户不会在 Paddle 确认的那一微秒看到新的订阅状态;他们会在下次页面渲染时看到。亚秒级传播的硬性要求与本架构不契合,除非扩展契约。

SaaS 计费为何拉胜于推。少一个需要保护的公共端点(无 HMAC 校验、无重放窗口、开发机无需公网 IP)。网关初始设置时管理员少一步集成(不必"创建端点、复制密钥、等待测试 webhook")。从厂商客户门户发起的取消与套餐变更仍会传播 — 下一次同步会拾取。同步节奏可在核心中配置,因此对新鲜度要求更严的安装可降低间隔。

核心中的基础契约

宿主提供四块基础供插件消费。插件不实现它们 — 它们调用它们。

BillingManager 注册表

app/Library/BillingManager.php 是一个 DI 绑定的单例,持有 网关类型 → 展示元数据 + 服务工厂 映射。插件的 ServiceProvider boot() 每厂商调用一次 Billing::register(...);客户面向的选择类型下拉框、管理员表单选择器与 Billing::resolveService($gateway) 都从该注册表读取。

Billing::register(
    string  $type,                 // 'paddle' — discriminator slug
    string  $name,                 // shown in admin select-type
    string  $description,          // 1-2 sentences shown alongside the name
    \Closure $serviceFactory,      // fn(PaymentGateway $gw): IntentGatewayInterface
    string  $icon = 'payment',     // Material Symbols Rounded ligature
    bool    $isRemoteSubscription = false,
    string  $formView = '',        // namespaced blade view for the admin gateway-config form
);

CheckoutHandlerInterface 回调

vendor/acelle/cashier/src/Contracts/CheckoutHandlerInterface.php 声明同步层在远程状态与本地意图状态分歧时触发的宿主侧回调。插件不直接调用它。宿主的 RemoteSubscriptionSyncService 消费插件的读方法,并自行派发到 CheckoutHandler::onPaymentSuccess / onSubscriptionCreated / onPaymentFailed / onPaymentRequiresAuth / onOfflineClaimReceived

PaymentIntent 状态机

五个终态或挂起状态;插件从不直接转换 intent 状态。宿主同步层通过插件读取厂商状态,并通过 CheckoutHandler 翻转 intent。

状态含义
PENDINGIntent 已创建,客户尚未付款
REQUIRES_ACTION3DS / SCA 挑战等待客户操作(仅卡支付)
AWAITING_ADMIN_APPROVAL离线索赔等待管理员审核
SUCCEEDED终态 — 付款已确认
FAILED / CANCELLED终态 — 连同厂商提供的原因呈现给客户

Cashier 包 — 八个内置网关作为参考

fork 后的 Cashier 包位于 /Users/luan/apps/cashier/src/Services/,提供八个内置网关实现。阅读它们是看到支付网关插件可能想要实现的完整表面最快的方式:StripePaymentGatewayStripeSubscriptionGatewayBraintreePaymentGatewayBraintreeSubscriptionGatewayPaypalPaymentGatewayPaystackPaymentGatewayRazorpayPaymentGatewayOfflinePaymentGateway。前两个是订阅形态;其余是一次性卡支付或电汇形态。

四个能力接口

四者都位于 Cashier 包的 /Users/luan/apps/cashier/src/Contracts/ 下。插件的网关类仅实现其厂商实际支持的接口 — 宿主在每个调用点做 instanceof 检查。

接口用途必填于
IntentGatewayInterface 基础契约 — getCheckoutUrl(intent, returnUrl) + 用于已保存方式展示的 getMethodTitle/getMethodInfo 每个网关
SupportsAutoChargeInterface 用于无重定向的离会话卡扣款的 autoCharge(intent, pmData) 支持一键续扣的网关(Stripe 一次性;Paddle 支持)
SupportsSubscriptionInterface 用于无头订阅创建的 createSubscription(intent, pmData) 支持无头订阅流程的网关(Stripe Subscription);Paddle 的 createSubscription 抛错,因为 Paddle 仅支持托管结账
RemoteSubscriptionGatewayInterface getRemotePlans / getRemoteSubscription / getRemoteSubscriptions / cancelRemoteSubscription / updateRemoteSubscriptionPlan / getRemotePaymentMethod — 读 / 同步侧 由厂商拥有订阅状态的网关(Paddle、Stripe Subscription)

插件脚手架

支付网关插件的完整文件布局:

storage/app/plugins/{author}/{name}/
├── composer.json                              ← plugin metadata + autoload + provider
├── routes.php                                 ← icon route + /cashier/{vendor}/checkout/{intent_uid}
├── icon.svg                                   ← admin Plugins page icon
├── src/
│   ├── ServiceProvider.php                    ← Billing::register + lifecycle hooks
│   ├── Services/
│   │   └── {Vendor}Gateway.php                ← implements IntentGateway[+ optional capability ifaces]
│   ├── Controllers/
│   │   └── {Vendor}CheckoutController.php     ← redirect to vendor's hosted checkout
│   └── Support/
│       └── {Vendor}Api.php                    ← Guzzle wrapper around vendor REST API
├── database/migrations/                       ← empty unless plugin owns its own table
├── resources/
│   ├── views/
│   │   └── form.blade.php                     ← admin gateway-config form (api keys, environment)
│   └── lang/
│       └── en/messages.php                    ← gateway name, description, form labels
└── tests/Unit/                                ← unit tests (capability contract)

注意没有的东西:没有 webhook 控制器,没有签名校验器,没有防重放表。状态同步由宿主拉取,不是由厂商推送。

ServiceProvider — 单次 Billing::register 调用注册一切

支付网关插件的完整 ServiceProvider 骨架(改写自 acelle/paddle):

namespace Acelle\Paddle;

class ServiceProvider extends Base
{
    public function register(): void
    {
        // Translation file — MUST be in register(), not boot. See /developers/translations.
        Hook::add('add_translation_file', fn () => [
            'translation_prefix' => 'paddle',
            'master_translation_file' => realpath(__DIR__.'/../resources/lang/en/messages.php'),
            // ...
        ]);
    }

    public function boot(): void
    {
        $this->loadViewsFrom(__DIR__.'/../resources/views', 'paddle');
        $this->loadRoutesFrom(__DIR__.'/../routes.php');

        // The single registry call.
        Billing::register(
            'paddle',
            trans('paddle::messages.gateway.name'),
            trans('paddle::messages.gateway.description'),
            fn ($gw) => new Services\PaddleGateway(
                apiKey:      (string) $gw->getGatewayData('api_key'),
                environment: (string) ($gw->getGatewayData('environment') ?: 'sandbox'),
            ),
            icon: 'rocket_launch',
            isRemoteSubscription: true,
            formView: 'paddle::form',
        );

        // Lifecycle — same pattern as every plugin.
        Hook::on('activate_plugin_acelle/paddle', fn () => \Artisan::call('migrate', [
            '--path'  => 'storage/app/plugins/acelle/paddle/database/migrations',
            '--force' => true,
        ]));
        Hook::on('delete_plugin_acelle/paddle', fn () => \Artisan::call('migrate:rollback', [
            '--path'  => 'storage/app/plugins/acelle/paddle/database/migrations',
            '--force' => true,
        ]));
    }
}

Billing::register 内的闭包通过 getGatewayData('key')PaymentGatewaygatewayData JSON 列读取凭据。这就是管理员的表单字段如何流入服务构造函数。

网关服务 — getCheckoutUrl 返回插件的 URL

当客户提交结账时,宿主立即调用 getCheckoutUrl。实现必须便宜且无副作用 — 渲染期间不进行厂商 API 调用:

public function getCheckoutUrl(PaymentIntent $intent, string $returnUrl): string
{
    return route('paddle.checkout', ['intent_uid' => $intent->uid])
        . '?return_url=' . urlencode($returnUrl);
}

URL 指向插件自己的控制器,而不是直接指向厂商 URL。三个理由:

  • 创建实际托管结账的厂商 API 调用(Paddle:POST /transactions)需要位于控制器边界之后,以便能捕获错误并把客户重定向回带闪存错误的发票页面。
  • 日志记录与节流属于控制器,而不属于网关服务。
  • 网关服务保持"纯净" — getCheckoutUrl 在 intent 创建时运行时没有 HTTP 副作用。getCheckoutUrl 可被推测性调用,包括在测试中。

结账控制器 — 调用厂商 + 302 到托管结账

插件的 CheckoutController::redirect() 才是真正发生厂商 API 调用的地方。客户访问 /cashier/paddle/checkout/{uid},控制器调用 Paddle 的 POST /transactions 端点,并把浏览器 302 到 Paddle 返回的托管结账 URL:

public function redirect(Request $request, string $intentUid)
{
    $intent  = PaymentIntent::where('uid', $intentUid)->firstOrFail();
    $service = Billing::resolveService($intent->paymentGateway);
    $returnUrl = (string) $request->query('return_url', url('/'));

    try {
        $tx = $service->createCheckoutTransaction(
            intentUid:     $intent->uid,
            currency:      (string) $intent->currency,
            amountMajor:   (float)  $intent->amount,
            customerEmail: $intent->invoice->billing_email,
            remotePriceId: $intent->metadata['remote_plan_id'] ?? null,
            returnUrl:     $returnUrl,
        );
    } catch (\Throwable $e) {
        \Log::error('Paddle checkout: createTransaction failed', [
            'intent_uid' => $intent->uid,
            'error'      => $e->getMessage(),
        ]);
        return redirect()->away($returnUrl)
            ->with('alert-error', trans('paddle::messages.checkout.create_failed', [
                'error' => $e->getMessage(),
            ]));
    }

    return redirect()->away($tx['data']['checkout']['url']);
}

createCheckoutTransactionPaddleGateway 上的插件内部方法(不在任何接口上)。它以 Paddle 特定的 JSON 形态包裹厂商的 POST /transactions

关键:custom_data.intent_uid 被发送给厂商并在后续读取时回显 — GET /subscriptions/{id} 返回相同的 custom_data。这就是同步层把 Paddle 订阅映射回本地 PaymentIntent 的方式。丢失这一步同步就会静默失败。

读侧映射器 — 喂养同步层

插件通过 RemoteSubscriptionGatewayInterface 方法暴露厂商状态。宿主的 RemoteSubscriptionSyncService(cron + 按需)调用它们以刷新本地 DTO,然后在状态分歧时派发到 CheckoutHandler

public function getRemoteSubscription(string $remoteSubId): RemoteSubscriptionDTO
{
    $response = $this->api->get("/subscriptions/{$remoteSubId}");
    return $this->subscriptionToDto($response['data'] ?? []);
}

public function getRemoteSubscriptions(?string $startingAfter = null, int $limit = 100): array
{
    $query = ['per_page' => min($limit, 200)];
    if ($startingAfter) {
        $query['after'] = $startingAfter;
    }
    $response = $this->api->get('/subscriptions', $query);
    return [
        'data'        => array_map([$this, 'subscriptionToDto'], $response['data'] ?? []),
        'has_more'    => $response['has_more'] ?? false,
        'next_cursor' => $response['next_cursor'] ?? null,
    ];
}

分页契约:返回 {data, has_more, next_cursor}。同步服务一直翻页直到 has_more => false。DTO 映射器(priceToDtosubscriptionToDto)把厂商的 JSON 形态翻译为宿主的中立 DTO 形态 — 这是最难共享的每厂商知识,因为每家厂商的响应形态都不同。

能力矩阵 — 四种厂商模式

插件实现哪些接口取决于厂商的支付模型。宿主目前已经支持的四种模式:

厂商模式实现示例
使用 token 的一次性卡扣款 IntentGatewayInterface + SupportsAutoChargeInterface Stripe 一次性、Square、Razorpay 一次性
托管结账订阅 IntentGatewayInterface + RemoteSubscriptionGatewayInterface + SupportsSubscriptionInterface(createSubscription 抛错) Paddle、Lemon Squeezy
无头订阅 IntentGatewayInterface + SupportsSubscriptionInterface + RemoteSubscriptionGatewayInterface Stripe Subscription、Braintree Subscription
人工 / 电汇 IntentGatewayInterface Offline(银行转账、现金)

不要实现未使用的接口。Billing::supportsRemoteSubscription($gw) 读取的是注册时设置的 isRemoteSubscription 标志,而非 instanceof — 但该标志必须与您的服务实际行为一致,请保持对齐。

测试能力契约

没有 webhook 代码,就没有需要单元测试的安全边界。请聚焦于能力契约 — 调用方依赖的网关形态保证。

应做单元测试的事

  • 对仅支持托管结账的厂商(Paddle),createSubscription 抛错 — 调用方据此知道改用 getCheckoutUrl
  • getCheckoutUrl 返回指向您插件控制器的路由,而不是厂商 URL — 证明控制器边界到位。
  • 当厂商返回已知形态时,getRemoteSubscription 返回填充好的 RemoteSubscriptionDTO — 用录制的固件演练 subscriptionToDto
  • 分页契约 — 多页的 getRemoteSubscriptions 一直翻页直到 has_more => false 并返回拼接后的数据。

不应做单元测试的事

  • 厂商 API 实时响应 — 这些需要真实沙箱账号,且超出单元测试范围。发布前用沙箱密钥 curl 端点做实时验证。
  • DTO 映射器 — 当它们是私有且与厂商 JSON 形态紧耦合时,应通过端到端测试 + 录制固件间接覆盖,或者仅当映射逻辑非平凡时才为测试暴露它们。
  • 路由 — Laravel 覆盖 loadRoutesFrom;只要闭包捕获了路由名,它在运行时就能解析。
  • Billing::register 顺利路径 — 宿主自己的 BillingManagerTest 已覆盖。

测试 深入指南,把插件的 testsuite 在宿主的 phpunit.xml 中注册:<testsuite name="Plugin: acelle/paddle"> ... </testsuite>。用 ./vendor/bin/pest --testsuite="Plugin: acelle/paddle" 运行该套件。

激活生命周期

事件发生什么
php artisan plugin:init author/namestorage/app/plugins/... 下生成文件。插入 status=inactive 的数据库行。ServiceProvider 自动加载。
管理员点击 Activate触发 activate_plugin_author/name → 插件的 Hook 运行迁移。数据库状态翻转为 active。网关现在在选择类型下拉框中可见。
管理员点击 Deactivate数据库状态翻转为 inactiveServiceProvider 仍然加载。路由仍可解析。直到下次进程启动之前,网关类型仍在 BillingManager 中。
管理员点击 Delete触发 delete_plugin_author/name → 插件的 Hook 回滚迁移。文件移除,数据库行删除,主文件条目清除。

关于停用语义的守卫:如果"停用 = 网关立即消失"对您的安装重要,请在 Billing::register 服务工厂闭包内检查 Plugin::getByName('myvendor/myplugin')->isActive(),若否则抛错。内置的 acelle/paddle 插件目前并未这样做 — 即使管理员将其停用,网关也仍可用(直到下次进程重启)。后续硬化;目前该模式记于 插件架构 § 为什么未激活的插件仍影响应用

厂商边界纪律

这些是仅靠本地状态测试遗漏的模式。每一条都来自一个在被人工核查发现之前已经发布的真实 bug。请在编写网关服务之前阅读本节。

1. 显式传递带单位字段 — 永远不要依赖厂商默认

仅有 Amount 是不够的。厂商把 amount 解释为某种货币的次单位;如果插件不传 Currency,厂商会回退到终端或账户的默认值。TBANK 插件(同类参考)最初在其 Init 载荷中遗漏了 Currency — 终端默认是 RUB,套餐是 USD,客户看到 "₽49",而商家相信自己收到了 "$49"。这个 bug 对本地数据库断言是隐形的,因为本地 intent 老老实实记录了 currency='USD';只有厂商的展示说出了真相。

// ❌ Silent default
$payload = ['Amount' => $amountMinor, 'OrderId' => $intentUid];

// ✅ Explicit, fail-loud on unknown currency
private const ISO4217_NUMERIC = ['RUB' => '643', 'USD' => '840', 'EUR' => '978', /* ... */];

if (!isset(self::ISO4217_NUMERIC[$iso = strtoupper($currency)])) {
    throw new \InvalidArgumentException(
        "Unsupported currency '{$currency}'. Supported: " .
        implode(', ', array_keys(self::ISO4217_NUMERIC))
    );
}
$payload['Currency'] = self::ISO4217_NUMERIC[$iso];

2. 在每个读端点映射回 custom_data

插件在结账创建时发送 custom_data.intent_uid。厂商在每个相关读取上回显 — 订阅详情、交易详情、支付方式详情。插件必须从每种形态中读回它,因为厂商的 custom_data 位置在不同读端点之间可能不同。其中任何一个丢失映射,同步就会让该 intent 永远静默停留在 PENDING

3. 永远不要从 getCheckoutUrl 抛出未处理异常

getCheckoutUrl 在页面渲染期间被调用。未处理的抛错会在客户期望看到结账按钮的地方产生 500 页面。让该方法保持无副作用;让控制器处理厂商调用失败并通过客户的闪存会话呈现错误。

4. 在控制器中捕获每次厂商调用并闪存错误

客户需要看到厂商说了什么,才能选择别的网关或联系支持。500 页面什么都告诉不了他们。Paddle 控制器的 try / catch + redirect()->away($returnUrl)->with('alert-error', ...) 是规范模式。

5. 用 intent UID 记录每次厂商调用

没有 intent UID 的日志行,调试客户报告的失败意味着翻阅成百上千条不相关日志。\Log::error('Paddle checkout: createTransaction failed', ['intent_uid' => $intent->uid, 'error' => $e->getMessage()]) 是最低限度有用的形态;运行多个网关的团队会再加上一个 'gateway' => 'paddle' 标签。

下一步去哪里

发送驱动(推模型 + webhook)与支付网关(拉模型 + 读取)是两篇最重的实战示例。两者一起覆盖了宿主当前支持的厂商边界谱系的两侧。下一页 — acelle/ai 案例 — 作为阅读理解练习端到端走完规范级复杂插件:八个 Eloquent 模型、十四个迁移、十八种语言、聊天框 UI 表面,以及生产中用到的每个 Hook 模式。当您准备做比驱动或网关更大的东西时,把它当作参考代码库。

当网关发布并上线后,运行 激活 → 测试 → 删除循环(来自测试深入指南) — 它能捕获一类单元测试无法捕获的 delete_plugin_* Hook bug。关于更广的跨页参考,插件架构 概览有完整的生命周期地图。