接手一个老项目的时候,我在支付模块的入口文件里看到了一段 400 多行的 switch。大概长这样:
switch ($request->param('channel')) {
case 'wechat':
// 30 行微信逻辑
break;
case 'alipay':
// 40 行支付宝逻辑
break;
case 'unionpay':
// 35 行银联逻辑
break;
case 'apple':
// 20 行苹果内购逻辑
break;
default:
// 20 行错误处理
}
每一段里面都写着「签名构造」「参数拼装」「发送请求」「结果解析」「日志记录」这五步,每步的细节各不相同,但骨架完全一样。更难受的是,这个 switch 在四个地方重复出现——下单、查单、退款、对账通知。
改天产品来说「接入一个 PayPal 吧」,我算了一下,得在四个 switch 里各加一个分支,加上配套的配置、验证、文档,至少两天。万一以后再来一个渠道,那就是又一个两天。
这就是写这篇文章的起因。前后花了一周时间改造,最后做到的效果是:新增一个渠道只需要新建一个类、写一个配置文件、注册到容器,业务代码一行不用动。
先理清可插拔的核心思想
可插拔架构这个词听着玄乎,核心其实很朴素:让业务代码依赖接口,而不是依赖具体实现。通过容器在运行时把接口解析成具体的实现类。
拿支付来说,业务方(下单流程)真正关心的只有三件事:创建订单、查询订单、申请退款。至于底层是调微信、支付宝还是别的,业务方不关心。所以我们应该定义一个 PaymentGateway 接口,里面就三个方法,然后让每个渠道各自实现。
业务代码只依赖这个接口。具体是哪个实现,交给容器去解析。这样加一个新的渠道,就等于往容器里加一个新的实现类,业务侧不用动。
定义接口
第一步,把支付行为抽象出来。这个接口是整件事的地基,得先想清楚。
<?php
namespace appcommonpayment;
/**
* 支付渠道统一接口
*/
interface PaymentGateway
{
/**
* 创建一笔支付订单
*
* @param array $order 业务侧统一格式的订单数据
* [
* 'order_no' => 'SO20260118000001',
* 'amount' => 9900, // 单位:分
* 'subject' => '月度会员',
* 'extra' => [...], // 各渠道特有字段
* ]
* @return array 统一返回格式
* [
* 'trade_no' => '渠道订单号',
* 'pay_url' => '支付链接', // 扫码支付场景
* 'payload' => [...], // 客户端需要的原始数据
* ]
*/
public function createOrder(array $order): array;
/**
* 查询订单状态
*
* @param string $orderNo 业务订单号
* @return array ['status' => 'paid|pending|failed', 'trade_no' => ...]
*/
public function queryOrder(string $orderNo): array;
/**
* 申请退款
*
* @param string $orderNo 业务订单号
* @param int $amount 退款金额,单位分
* @param string $reason 退款原因
* @return array ['refund_no' => ..., 'status' => ...]
*/
public function refund(string $orderNo, int $amount, string $reason): array;
/**
* 解析支付渠道异步通知
*
* @param array $raw 原始推送数据(各渠道不同)
* @return array ['order_no' => ..., 'trade_no' => ..., 'status' => ...]
*/
public function parseNotify(array $raw): array;
}
四个方法,覆盖了支付场景的主要动作。注意几个设计细节:
- 金额统一用「分」为单位,整数,避免浮点精度问题。这是支付系统的铁律,不管渠道本身用什么单位,接口层统一。
- 返回值也是统一格式,各渠道要把自己特有的格式转换过来。业务侧看到的所有返回值长得都一样。
- 异常统一抛出业务异常,不要泄露渠道特有的错误码出来,那是实现细节。
实现三个渠道
接口定下来之后,各渠道的实现类写起来就顺了。因为约束明确,每个类只需要考虑「怎么把参数转成本渠道格式」「怎么调用」「怎么转换返回结果」。
先看微信支付的实现:
<?php
namespace appcommonpaymentgateway;
use appcommonpaymentPaymentGateway;
use appcommonpaymentexceptionPaymentException;
use thinkfacadeConfig;
use thinkfacadeHttp;
class WechatGateway implements PaymentGateway
{
private array $config;
public function __construct()
{
$this->config = Config::get('payment.gateways.wechat', []);
if (empty($this->config['mch_id']) || empty($this->config['api_key'])) {
throw new PaymentException('微信支付配置不完整');
}
}
public function createOrder(array $order): array
{
$params = [
'appid' => $this->config['app_id'],
'mch_id' => $this->config['mch_id'],
'out_trade_no' => $order['order_no'],
'total_fee' => $order['amount'],
'body' => $order['subject'],
'nonce_str' => md5(uniqid('', true)),
'notify_url' => $this->config['notify_url'],
'trade_type' => 'NATIVE',
];
$params['sign'] = $this->sign($params);
$response = Http::post('https://api.mch.weixin.qq.com/pay/unifiedorder', $params);
$result = $this->parseXml($response);
if (($result['return_code'] ?? '') !== 'SUCCESS' || ($result['result_code'] ?? '') !== 'SUCCESS') {
throw new PaymentException(
'微信下单失败:' . ($result['return_msg'] ?? '') . ' / ' . ($result['err_code_des'] ?? '')
);
}
return [
'trade_no' => $result['prepay_id'],
'pay_url' => $result['code_url'],
'payload' => [
'prepay_id' => $result['prepay_id'],
],
];
}
public function queryOrder(string $orderNo): array
{
$params = [
'appid' => $this->config['app_id'],
'mch_id' => $this->config['mch_id'],
'out_trade_no' => $orderNo,
'nonce_str' => md5(uniqid('', true)),
];
$params['sign'] = $this->sign($params);
$response = Http::post('https://api.mch.weixin.qq.com/pay/orderquery', $params);
$result = $this->parseXml($response);
$statusMap = [
'SUCCESS' => 'paid',
'REFUND' => 'refunded',
'NOTPAY' => 'pending',
'CLOSED' => 'failed',
'REVOKED' => 'failed',
'PAYERROR' => 'failed',
];
return [
'status' => $statusMap[$result['trade_state'] ?? ''] ?? 'unknown',
'trade_no' => $result['transaction_id'] ?? '',
];
}
public function refund(string $orderNo, int $amount, string $reason): array
{
$params = [
'appid' => $this->config['app_id'],
'mch_id' => $this->config['mch_id'],
'out_trade_no' => $orderNo,
'out_refund_no' => $orderNo . '-R' . time(),
'total_fee' => $amount,
'refund_fee' => $amount,
'refund_desc' => $reason,
'nonce_str' => md5(uniqid('', true)),
];
$params['sign'] = $this->sign($params);
$response = Http::post('https://api.mch.weixin.qq.com/secapi/pay/refund', $params);
$result = $this->parseXml($response);
if (($result['result_code'] ?? '') !== 'SUCCESS') {
throw new PaymentException('微信退款失败:' . ($result['err_code_des'] ?? ''));
}
return [
'refund_no' => $result['out_refund_no'],
'status' => 'processing',
];
}
public function parseNotify(array $raw): array
{
// 微信回调是 XML,通常业务侧已经转成数组传进来
if (!$this->verifyNotify($raw)) {
throw new PaymentException('微信回调签名验证失败');
}
return [
'order_no' => $raw['out_trade_no'] ?? '',
'trade_no' => $raw['transaction_id'] ?? '',
'status' => ($raw['result_code'] ?? '') === 'SUCCESS' ? 'paid' : 'failed',
];
}
private function sign(array $params): string
{
ksort($params);
$pairs = [];
foreach ($params as $k => $v) {
if ($v === '' || $k === 'sign') continue;
$pairs[] = $k . '=' . $v;
}
$str = implode('&', $pairs) . '&key=' . $this->config['api_key'];
return strtoupper(md5($str));
}
private function parseXml(string $xml): array
{
$obj = simplexml_load_string($xml, 'SimpleXMLElement', LIBXML_NOCDATA);
return json_decode(json_encode($obj), true) ?: [];
}
private function verifyNotify(array $raw): bool
{
$sign = $raw['sign'] ?? '';
unset($raw['sign']);
return $this->sign($raw) === $sign;
}
}
支付宝网关里的结构完全一样,只是内部细节不同:
<?php
namespace appcommonpaymentgateway;
use appcommonpaymentPaymentGateway;
use appcommonpaymentexceptionPaymentException;
use thinkfacadeConfig;
use thinkfacadeHttp;
class AlipayGateway implements PaymentGateway
{
private array $config;
public function __construct()
{
$this->config = Config::get('payment.gateways.alipay', []);
if (empty($this->config['app_id']) || empty($this->config['private_key'])) {
throw new PaymentException('支付宝配置不完整');
}
}
public function createOrder(array $order): array
{
$params = [
'app_id' => $this->config['app_id'],
'method' => 'alipay.trade.precreate',
'charset' => 'utf-8',
'sign_type' => 'RSA2',
'timestamp' => date('Y-m-d H:i:s'),
'version' => '1.0',
'notify_url' => $this->config['notify_url'],
'biz_content' => json_encode([
'out_trade_no' => $order['order_no'],
'total_amount' => number_format($order['amount'] / 100, 2, '.', ''),
'subject' => $order['subject'],
], JSON_UNESCAPED_UNICODE),
];
$params['sign'] = $this->sign($params);
$response = Http::post('https://openapi.alipay.com/gateway.do', $params);
$result = json_decode($response, true);
if (($result['alipay_trade_precreate_response']['code'] ?? '') !== '10000') {
throw new PaymentException(
'支付宝下单失败:' . ($result['alipay_trade_precreate_response']['sub_msg'] ?? '')
);
}
return [
'trade_no' => $result['alipay_trade_precreate_response']['out_trade_no'],
'pay_url' => $result['alipay_trade_precreate_response']['qr_code'],
'payload' => [],
];
}
public function queryOrder(string $orderNo): array
{
// 结构类似,略
return [];
}
public function refund(string $orderNo, int $amount, string $reason): array
{
// 结构类似,略
return [];
}
public function parseNotify(array $raw): array
{
if (!$this->verifyNotify($raw)) {
throw new PaymentException('支付宝回调签名验证失败');
}
return [
'order_no' => $raw['out_trade_no'] ?? '',
'trade_no' => $raw['trade_no'] ?? '',
'status' => ($raw['trade_status'] ?? '') === 'TRADE_SUCCESS' ? 'paid' : 'failed',
];
}
private function sign(array $params): string
{
// RSA2 签名实现略
return '';
}
private function verifyNotify(array $raw): bool
{
// 验签实现略
return true;
}
}
PayPal 也一样,写一个 PaypalGateway 实现那个接口。三个类的代码量都不少,但复杂度被隔离在各自文件里了。以后要改微信的签名逻辑,不会碰到支付宝的任何代码。
用容器绑定接口到实现
这是 ThinkPHP 8 容器最实用的功能之一。你可以告诉容器:「当有人问我要一个 PaymentGateway 的时候,根据当前渠道名,给我具体的实现。」
ThinkPHP 8 的容器有一个 bind() 方法可以做这件事。最直接的写法:
<?php
// app/service/PaymentService.php 或专门的 Provider
use appcommonpaymentPaymentGateway;
use appcommonpaymentgatewayWechatGateway;
use appcommonpaymentgatewayAlipayGateway;
use appcommonpaymentgatewayPaypalGateway;
use thinkApp;
class PaymentServiceProvider
{
public function register(App $app): void
{
$app->bind(PaymentGateway::class, function () {
$channel = request()->param('channel') ?: config('payment.default');
return match ($channel) {
'wechat' => new WechatGateway(),
'alipay' => new AlipayGateway(),
'paypal' => new PaypalGateway(),
default => throw new InvalidArgumentException("未知支付渠道:{$channel}"),
};
});
}
}
这里有个关键的选择:用什么作为渠道标识的来源。
上面这个绑定里,我从 request()->param('channel') 里取。这是最常见的场景——每次支付请求由前端传一个 channel 参数。但问题是,如果业务里从 HTTP 请求拿不到这个参数(比如命令行任务、队列消费),就会拿不到渠道。
我的做法是引入一层 ChannelResolver,把渠道来源和容器绑定解耦:
<?php
namespace appcommonpayment;
class ChannelResolver
{
private static ?string $forced = null;
private static array $forcedStack = [];
/** 强制指定渠道(在特定作用域内) */
public static function withChannel(string $channel, callable $callback): mixed
{
self::$forcedStack[] = self::$forced;
self::$forced = $channel;
try {
return $callback();
} finally {
self::$forced = array_pop(self::$forcedStack);
}
}
public static function resolve(): string
{
if (self::$forced !== null) {
return self::$forced;
}
// 优先级:请求参数 > 请求头 > 用户默认偏好 > 全局默认
$channel = request()->param('channel')
?: request()->header('X-Payment-Channel')
?: null;
return $channel ?: config('payment.default', 'alipay');
}
}
然后容器绑定改成:
$app->bind(PaymentGateway::class, function () {
$channel = ChannelResolver::resolve();
return match ($channel) {
'wechat' => new WechatGateway(),
'alipay' => new AlipayGateway(),
'paypal' => new PaypalGateway(),
default => throw new InvalidArgumentException("未知支付渠道:{$channel}"),
};
});
业务代码里如果需要在一个请求里操作两个渠道(比如针对同一订单在不同渠道做退款),可以直接:
ChannelResolver::withChannel('wechat', function () use ($app) {
$gateway = $app->make(PaymentGateway::class);
$gateway->refund($orderNo, $amount, '渠道切换测试');
});
这个 withChannel 有点像 Java 里的 ScopedValue,作用域结束自动恢复。这在多线程(协程)环境下要注意一下,不过 ThinkPHP 默认是同步阻塞的,问题不大。等你上 Swoole 的时候,得改成协程上下文变量。
业务代码怎么用
有了这套绑定,业务侧的代码就特别清爽:
<?php
namespace appservice;
use appcommonpaymentPaymentGateway;
class OrderService
{
public function __construct(
private readonly PaymentGateway $payment
) {
}
public function createPayment(int $orderId): array
{
$order = Order::findOrFail($orderId);
$result = $this->payment->createOrder([
'order_no' => $order->sn,
'amount' => $order->total_fee,
'subject' => $order->subject,
]);
$order->payment_trade_no = $result['trade_no'];
$order->save();
return $result;
}
}
构造函数注入一个 PaymentGateway,用就行了。这个类完全不知道底层是微信还是支付宝,也不知道渠道是怎么决定的。太干净了。
控制器里的调用就一行:
public function pay(Request $request, OrderService $orderService)
{
return json($orderService->createPayment((int)$request->param('order_id')));
}
加一个渠道要做什么
现在回到最开始的问题:如果产品说「接入 PayPal」,我们要做几件事?
- 新建
app/common/payment/gateway/PaypalGateway.php,实现PaymentGateway接口。 - 在
config/payment.php的gateways里加一段 paypal 的配置。 - 在容器绑定的 match 里加一行
'paypal' => new PaypalGateway()。 - 写测试。
业务代码、控制器、中间件、幂等逻辑、订单状态的代码,全部不用动。
如果是四个 switch 的老写法,这里至少要在十多个地方改代码。差异很大。
一个容易忽略的细节:延迟加载
上面容器绑定的写法其实有个隐藏的性能问题:每次 $app->make(PaymentGateway::class) 都会创建一个新的实例。如果你的 Gateway 构造函数里做了很重的初始化(比如读证书、连远程配置中心),每次请求都跑一遍,开销不小。
ThinkPHP 的容器默认不是单例。要在一次请求内复用,可以用 singleton():
$app->singleton(PaymentGateway::class, function () {
return match (ChannelResolver::resolve()) {
'wechat' => new WechatGateway(),
'alipay' => new AlipayGateway(),
'paypal' => new PaypalGateway(),
default => throw new InvalidArgumentException('未知渠道'),
};
});
但要注意:singleton 一次请求内只会创建一次,如果同一个请求里想切换渠道,第二次 make 拿到的还是第一次的实例。这就是上面 withChannel 场景下需要特别注意的地方。解决方案是用 withChannel 的同时手动 make,不走 singleton:
ChannelResolver::withChannel('wechat', function () use ($app) {
// 用 make 而不是依赖注入的自动解析
$gateway = $app->make(PaymentGateway::class, [], true); // 第三个参数强制新建
// ...
});
这块细节文档里不太提,但坑发生在线上就是几十条支付失败的告警。踩过就知道。
异常怎么统一处理
各个渠道抛出的异常格式不一样,但业务侧只关心「是业务错误还是系统错误」这一个区分。这里可以用一个专门的异常类型包裹:
<?php
namespace appcommonpaymentexception;
class PaymentException extends RuntimeException
{
private ?string $channel;
private ?string $channelErrorCode;
public static function fromChannel(
string $channel,
string $code,
string $message
): self {
$e = new self("[$channel] $message");
$e->channel = $channel;
$e->channelErrorCode = $code;
return $e;
}
public function getChannel(): ?string { return $this->channel; }
public function getChannelErrorCode(): ?string { return $this->channelErrorCode; }
}
然后在全局异常处理里捕获这个类型的异常,返回给业务侧一个统一格式的错误响应:
<?php
// app/common/exception/Handler.php
public function render($request, Throwable $e)
{
if ($e instanceof PaymentException) {
return json([
'code' => 40001,
'message' => $e->getMessage(),
'channel' => $e->getChannel(),
], 400);
}
return parent::render($request, $e);
}
这样上游的业务代码 catch 一下 PaymentException 就能拿到所有细节,不需要关心底层是哪个渠道。日志里也会有完整的渠道名和错误码,排查的时候很方便。
两个坑和它们的规避方式
坑一:接口里的方法太细。我第一版接口里定义了七个方法,后来发现有四个是少数渠道才有的特殊能力(比如「关闭订单」只有微信和支付宝支持,「订阅扣款」只有 PayPal 独有)。硬塞进通用接口里,导致所有网关都要实现它们,有的直接抛「不支持」,很难看。
解决方式是:通用接口只放三到四个所有渠道都必须有的方法,特殊能力用额外的 marker interface 表达:
interface SupportsCloseOrder
{
public function closeOrder(string $orderNo): void;
}
// 使用时先判断
if ($gateway instanceof SupportsCloseOrder) {
$gateway->closeOrder($orderNo);
}
这样每个渠道只需要声明自己真正支持的能力,其他的保持不实现。
坑二:日志里打印了敏感信息。一开始我在网关类的入口和出口都打了完整的请求和响应日志,方便排查。上线第一周就发现日志文件里全是用户的手机号、身份证、银行卡部分号。合规上这是违规的。
处理方式是:日志统一走一个脱敏工具:
class LogSanitizer
{
private const SENSITIVE_KEYS = [
'id_card', 'card_no', 'phone', 'mobile', 'id_number',
'password', 'private_key', 'api_key',
];
public static function sanitize(array $data): array
{
foreach ($data as $k => $v) {
if (is_array($v)) {
$data[$k] = self::sanitize($v);
continue;
}
if (in_array(strtolower($k), self::SENSITIVE_KEYS, true)) {
$data[$k] = '***';
}
}
return $data;
}
}
网关在打日志之前先跑一遍脱敏。这是个不起眼的工具类,但真的能救一次事故。
测试
有了接口约束,测试就很好写了。业务侧的测试用一个 mock 实现:
class FakeGateway implements PaymentGateway
{
public array $calls = [];
public function createOrder(array $order): array
{
$this->calls[] = ['method' => 'createOrder', 'args' => $order];
return ['trade_no' => 'FAKE_' . $order['order_no'], 'pay_url' => '', 'payload' => []];
}
public function queryOrder(string $orderNo): array
{
return ['status' => 'paid', 'trade_no' => 'FAKE_' . $orderNo];
}
public function refund(string $orderNo, int $amount, string $reason): array
{
return ['refund_no' => 'REF_' . $orderNo, 'status' => 'processing'];
}
public function parseNotify(array $raw): array
{
return $raw;
}
}
// 测试里
$app->bind(PaymentGateway::class, fn() => new FakeGateway());
$service = $app->make(OrderService::class);
$service->createPayment(1);
// 断言
每个渠道自己的单元测试就只测「参数转换逻辑」和「响应解析逻辑」,不依赖远程接口。用 HTTP mock 或者录制的响应数据作为 fixture 就够了。
写在最后
这套架构跑了半年多,中间接了两个新渠道,每次都是半天搞定的。同事跟我说「你不在的时候我们自己加了一个,照着你那个 PaypalGateway 抄的,一次就对」。那一刻我觉得这一周的重构值了。
其实「可插拔」不是一个具体的技术,而是一种思维方式:什么时候把变化隔离在一个小盒子里,什么时候把事暴露到接口上,什么时候让调用方完全不知道实现细节。想清楚这三个问题,用什么语言、什么框架都一样。
ThinkPHP 8 的容器虽然不如 Spring 或者 Laravel 那么花哨,但该有的能力都有,用好它足以完成大部分解耦需求。真要说有什么建议,就是这条:业务代码永远依赖接口,别依赖具体类。这是每写一行代码时都要问自己的问题。

