为什么是插件而非核心
通过编辑 <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。
| 状态 | 含义 |
PENDING | Intent 已创建,客户尚未付款 |
REQUIRES_ACTION | 3DS / SCA 挑战等待客户操作(仅卡支付) |
AWAITING_ADMIN_APPROVAL | 离线索赔等待管理员审核 |
SUCCEEDED | 终态 — 付款已确认 |
FAILED / CANCELLED | 终态 — 连同厂商提供的原因呈现给客户 |
Cashier 包 — 八个内置网关作为参考
fork 后的 Cashier 包位于 /Users/luan/apps/cashier/src/Services/,提供八个内置网关实现。阅读它们是看到支付网关插件可能想要实现的完整表面最快的方式:StripePaymentGateway、StripeSubscriptionGateway、BraintreePaymentGateway、BraintreeSubscriptionGateway、PaypalPaymentGateway、PaystackPaymentGateway、RazorpayPaymentGateway、OfflinePaymentGateway。前两个是订阅形态;其余是一次性卡支付或电汇形态。
四个能力接口
四者都位于 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') 从 PaymentGateway 的 gatewayData 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']);
}
createCheckoutTransaction 是 PaddleGateway 上的插件内部方法(不在任何接口上)。它以 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 映射器(priceToDto、subscriptionToDto)把厂商的 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/name | 在 storage/app/plugins/... 下生成文件。插入 status=inactive 的数据库行。ServiceProvider 自动加载。 |
| 管理员点击 Activate | 触发 activate_plugin_author/name → 插件的 Hook 运行迁移。数据库状态翻转为 active。网关现在在选择类型下拉框中可见。 |
| 管理员点击 Deactivate | 数据库状态翻转为 inactive。ServiceProvider 仍然加载。路由仍可解析。直到下次进程启动之前,网关类型仍在 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。关于更广的跨页参考,插件架构 概览有完整的生命周期地图。