PAYATHON 2026

E-commerce 生产环境如何安全测试 checkout?

支付阿杰

结论

可以在生产站点验证 checkout,但支付交易必须发送到支付网关的 Sandbox/Test 环境,不能在生产支付通道中使用所谓的“特殊测试卡”。

建议建立受控的内部测试模式,并满足以下条件:

  • 只有通过身份验证的内部测试人员才能启用;
  • 是否进入测试模式由服务端决定,不能依赖前端参数;
  • 测试订单使用独立的支付网关测试凭据;
  • 测试订单与真实订单明确隔离;
  • 测试订单不会进入发货、财务、积分、营销等后续流程;
  • 通过 VPN、IP 白名单、角色权限或短期授权进一步限制测试模式;
  • 测试结束后可以自动清理或归档相关数据。

支付网关提供的测试卡通常只在测试环境中有效。只要服务端没有切换到测试凭据,普通客户即使知道测试卡号,也无法用它在真实支付环境中完成付款。

为什么不应该在生产通道中设置“万能测试卡”

不要在业务代码中加入这样的逻辑:

if ($cardNumber === '4242424242424242') {
    return paymentSuccessful();
}

这种做法绕过了真正的支付流程,还会带来一系列风险:

  • 测试卡号可能从日志、浏览器记录或内部文档中泄露;
  • 攻击者可能绕过页面,直接调用支付接口;
  • 无法验证支付网关请求、回调、签名和状态转换;
  • 可能误触发发货、库存扣减、积分和财务记录;
  • 条件判断或环境配置一旦出错,可能形成免费下单漏洞;
  • 自行接触完整卡号还会扩大 PCI DSS 合规范围。

测试模式应由服务端根据当前操作者是否获得授权来决定,不能通过卡号、优惠码、URL 参数或客户端隐藏字段触发。

推荐的整体结构

生产站点可以同时连接两套支付环境:

普通客户
  └─ checkout
      └─ Live gateway credentials
          └─ 真实支付

内部测试人员
  └─ checkout
      └─ Test gateway credentials
          └─ Sandbox/Test 支付

这里所说的“生产环境测试”,是指测试人员使用真实部署的页面、应用服务器和数据库流程,但支付请求会被路由到网关的测试环境。

这种方式能够验证生产部署后的大部分 checkout 逻辑,却不能证明真实收单链路一定正常。真实凭据、银行风控、3-D Secure、地区限制和生产 webhook 配置仍可能出现只存在于 Live 环境的问题。因此,通常还需要偶尔执行一笔小额真实交易,再立即退款或撤销。

Laravel 中的实现方式

以下代码只展示通用结构。具体 SDK 类名和参数应以所用支付网关的官方文档为准。

1. 分开保存测试和生产凭据

PAYMENT_LIVE_PUBLIC_KEY=...
PAYMENT_LIVE_SECRET_KEY=...

PAYMENT_TEST_PUBLIC_KEY=...
PAYMENT_TEST_SECRET_KEY=...

不要通过修改整个应用的 APP_ENV 来切换支付模式。生产应用仍处于 production 环境,只是经过授权的 checkout 会话会使用测试支付客户端。

配置文件中可以分别保存两套配置:

<?php

// config/payments.php

return [
    'live' => [
        'public_key' => env('PAYMENT_LIVE_PUBLIC_KEY'),
        'secret_key' => env('PAYMENT_LIVE_SECRET_KEY'),
    ],

    'test' => [
        'public_key' => env('PAYMENT_TEST_PUBLIC_KEY'),
        'secret_key' => env('PAYMENT_TEST_SECRET_KEY'),
    ],
];

2. 使用中间件限制测试模式

测试权限至少要检查登录状态和角色。条件允许时,还可以增加 VPN、IP 白名单和多因素认证。

<?php

namespace App\Http\Middleware;

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

final class RequireCheckoutTester
{
    public function handle(Request $request, Closure $next): Response
    {
        $user = $request->user();

        abort_unless(
            $user !== null
            && $user->hasRole('checkout-tester')
            && config('features.production_checkout_testing'),
            403
        );

        return $next($request);
    }
}

相关路由必须放在授权中间件之后:

use App\Http\Controllers\TestCheckoutController;
use App\Http\Middleware\RequireCheckoutTester;
use Illuminate\Support\Facades\Route;

Route::middleware(['auth', RequireCheckoutTester::class])
    ->prefix('internal/checkout-test')
    ->group(function () {
        Route::post('/session', [TestCheckoutController::class, 'create']);
        Route::post('/orders', [TestCheckoutController::class, 'store']);
    });

不要用下面这种方式切换模式:

/checkout?test=true

查询参数、Cookie 和前端隐藏字段都能被客户伪造。客户端最多只能表达请求意图,服务端必须重新检查并作出最终授权决定。

3. 由服务端选择支付客户端

可以用枚举表示支付模式:

<?php

namespace App\Payments;

enum PaymentMode: string
{
    case Live = 'live';
    case Test = 'test';
}

服务端完成授权后,再根据结果选择凭据:

<?php

namespace App\Payments;

final class PaymentClientFactory
{
    public function make(PaymentMode $mode): PaymentGateway
    {
        $credentials = config("payments.{$mode->value}");

        return new PaymentGateway(
            publicKey: $credentials['public_key'],
            secretKey: $credentials['secret_key'],
        );
    }
}

控制器不能接受客户端自由提交的 payment_mode:

<?php

namespace App\Http\Controllers;

use App\Models\Order;
use App\Payments\PaymentClientFactory;
use App\Payments\PaymentMode;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

final class TestCheckoutController
{
    public function store(
        Request $request,
        PaymentClientFactory $factory
    ): JsonResponse {
        // 当前路由已由 RequireCheckoutTester 保护。
        $mode = PaymentMode::Test;
        $gateway = $factory->make($mode);

        $order = Order::create([
            'user_id' => $request->user()->id,
            'payment_mode' => $mode->value,
            'is_test' => true,
            'status' => 'pending',
        ]);

        $payment = $gateway->createPayment([
            'amount' => $order->total_amount,
            'currency' => $order->currency,
            'metadata' => [
                'order_id' => (string) $order->id,
                'environment' => 'production-app-test',
            ],
        ]);

        return response()->json([
            'order_id' => $order->id,
            'payment_token' => $payment->clientToken(),
        ]);
    }
}

PaymentGateway 和 createPayment() 只是示意接口,并不对应某个支付 SDK 的真实 API。

如果支付网关需要在浏览器中初始化支付组件,受保护的服务端接口还要返回相应模式的 public key 或 client token。不能让前端使用测试 public key,而后端使用 live secret key。

4. 在数据库中永久标记测试订单

不要只在 session 中保存测试状态。订单进入异步队列、webhook 或后台任务后,session 信息将无法使用。

建议至少保存以下字段:

Schema::table('orders', function ($table) {
    $table->boolean('is_test')->default(false)->index();
    $table->string('payment_mode', 16)->default('live');
});

这些状态应在创建订单时写入。写入后,普通接口不能把 is_test 从 true 改成 false。

测试订单还可以使用醒目的编号前缀,例如:

TEST-20260901-000123

编号只能帮助识别订单,不能代替数据库中的可信字段。

隔离后续业务流程

支付完成只是 checkout 流程的一部分。测试订单很容易污染下游系统,因此,所有可能产生现实影响的消费者都要检查订单类型。

例如,发货任务必须拒绝测试订单:

<?php

namespace App\Jobs;

use App\Models\Order;

final class DispatchShipment
{
    public function handle(): void
    {
        $order = Order::findOrFail($this->orderId);

        if ($order->is_test) {
            return;
        }

        // 调用真实仓储或物流接口
    }
}

通常还要在以下环节进行类似检查:

  • 仓库和物流;
  • 库存扣减;
  • 发票与税务系统;
  • ERP 和会计系统;
  • 积分、返现和优惠券核销;
  • 销售报表与转化统计;
  • 商家、供应商和客户通知;
  • 营销自动化;
  • 风控和人工审核队列。

更稳妥的办法是让测试订单进入独立的事件主题或队列,例如:

orders.live.paid
orders.test.paid

这样,真实履约消费者不会订阅测试事件,也就不必依赖每个任务中的条件判断来避免误处理。

正确处理 webhook

Test 和 Live webhook 应分别使用不同的签名密钥,并尽量使用不同的入口:

Route::post('/webhooks/payment/live', LivePaymentWebhookController::class);
Route::post('/webhooks/payment/test', TestPaymentWebhookController::class);

处理 webhook 时需要检查三件事:

  1. 使用对应环境的 webhook secret 验证签名;
  2. 确认事件环境与订单的 payment_mode 一致;
  3. 拒绝 Test 事件修改 Live 订单,也拒绝 Live 事件修改 Test 订单。

示意代码:

if ($event->mode() !== $order->payment_mode) {
    report(new RuntimeException('Payment mode mismatch'));

    abort(400);
}

不能只根据 webhook 请求中的 order_id 更新订单。攻击者可能尝试构造请求,测试环境中的合法事件也可能因为配置错误而关联到真实订单。

如何做到“其他人无法使用测试卡”

重点不是保密测试卡号,而是阻止普通客户进入测试支付环境。

可以采用多层限制:

  • 只允许公司账号或专用测试账号;
  • 设置独立的 checkout-tester 权限;
  • 要求 MFA;
  • 只允许通过公司 VPN 或固定出口 IP 访问;
  • 使用短期授权,并设置明确的过期时间;
  • 每次启用测试模式时,记录操作者、订单、IP、时间和请求 ID;
  • 限制测试订单的数量和金额;
  • 通过 feature flag 控制测试功能,以便随时全局关闭;
  • 权限校验失败时直接返回 403,不能自动降级为 Live 支付。

测试卡号本身不应被视为密钥。即使卡号公开,只要测试模式有严格的服务端权限控制,普通客户也无法用它获得真实商品。

其他可选方案的局限

100% 优惠券

把订单金额降到零,可以验证购物车、地址、税费和订单创建,但通常会跳过支付网关,因而无法验证:

  • 支付组件;
  • token 创建;
  • 支付授权;
  • 3-D Secure;
  • webhook;
  • 支付失败和重试逻辑。

这种方式适合测试“免支付订单”,不能完整验证支付流程。

小额真实交易后立即退款

这是最接近真实情况的 Live 链路验证方式,但它仍会处理真实银行卡交易,也可能产生手续费、风控记录或退款等待时间。

如果必须测试生产收单环境,可以使用公司专用测试卡或费用卡,限制额度和使用人,并在支付完成后立即执行 void 或 refund。是否会产生费用,以及能否撤销授权,要看支付服务商和收单机构的具体规则。

在生产支付接口中使用网关测试卡

多数支付服务商的测试卡只能配合测试凭据或测试端点使用。不要认为测试卡可以安全地发送到 Live API,也不要自行设计特殊卡号。具体行为应以当前支付网关的官方文档为准。

上线前的检查清单

启用生产站点测试模式前,至少要确认:

  • 普通用户无法访问测试 checkout 路由;
  • 支付模式完全由服务端决定;
  • Test 和 Live API 凭据分开存储;
  • Test 和 Live webhook secret 不共用;
  • 测试事件无法更新真实订单;
  • 测试订单不会触发发货、开票或真实通知;
  • 日志不会记录卡号、CVC 或完整支付凭据;
  • 测试操作有审计记录和金额限制;
  • 测试模式可以快速关闭;
  • 自动化测试覆盖“未授权用户无法进入测试模式”;
  • 定期检查是否有测试订单进入真实下游系统。

可靠的方案不是一张只有开发人员知道的“万能卡”,而是一条在权限、支付凭据、订单数据、webhook 和履约系统之间都有明确隔离的测试链路。

备注:内容仅供参考。