ThinkPHP 8 可插拔架构实战:一套容器绑定搞定多渠道支付切换

2026-10-05 0 464

接手一个老项目的时候,我在支付模块的入口文件里看到了一段 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」,我们要做几件事?

  1. 新建 app/common/payment/gateway/PaypalGateway.php,实现 PaymentGateway 接口。
  2. 在 config/payment.php 的 gateways 里加一段 paypal 的配置。
  3. 在容器绑定的 match 里加一行 'paypal' => new PaypalGateway()。
  4. 写测试。

业务代码、控制器、中间件、幂等逻辑、订单状态的代码,全部不用动。

如果是四个 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 那么花哨,但该有的能力都有,用好它足以完成大部分解耦需求。真要说有什么建议,就是这条:业务代码永远依赖接口,别依赖具体类。这是每写一行代码时都要问自己的问题。

ThinkPHP 8 可插拔架构实战:一套容器绑定搞定多渠道支付切换
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请发送邮件1506151422@qq.com联系,将会及时下架删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 thinkphp ThinkPHP 8 可插拔架构实战:一套容器绑定搞定多渠道支付切换 https://www.taomawang.com/server/thinkphp/2893.html

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务